Compare commits

...
37 Commits
Author SHA1 Message Date
tianlei.qjb f10a0599d2 chore: prepare release v1.0.5 2026-04-01 10:48:04 +08:00
coffeeBigSir fc49f3dc7a Merge pull request #59 from audanye-sudo/feat/base-experience-upgrade
fix: error diagnostics enhancement and logging reliability
2026-04-01 09:37:21 +08:00
audanye-sudo 8fb0ecb9ba fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging
- resolveVerbosity: use cmd.Flags() instead of PersistentFlags() to correctly
  pick up inherited --verbose/--debug flags on subcommands
- FileLogger lazy binding: bind in executeInvocation since configureLogLevel
  initializes it after runner construction
- logBusinessError: log MCP tool errors and business errors (HTTP 200 +
  success=false) to file logger for offline diagnosis
2026-04-01 09:33:09 +08:00
audanye-sudo ce6c32bf53 feat: enhance error diagnostics with trace ID, server error code, and comprehensive logging
Improve CLI error output and local logging to enable offline issue diagnosis:

- Add ServerDiagnostics struct to extract and propagate trace_id, server_error_code,
  technical_detail, and server_retryable from MCP server responses
- Extract diagnostics from JSON-RPC error.data, tool call result content, and HTTP
  response headers (X-Trace-Id, X-Request-Id, x-dingtalk-trace-id)
- Redesign PrintHuman with three verbosity levels (Normal/Verbose/Debug):
  Normal shows trace ID + server code; Verbose adds technical detail;
  Debug adds internal diagnostics (RPC code, operation, reason)
- Add PrintHumanAt for explicit verbosity control, PrintHuman defaults to Normal
- Enhance local logging with request body (sanitized), response body (on error),
  retry attempts, and error classification events
- Add TruncateBody, SanitizeArguments, RedactHeaders logging utilities with
  sensitive key detection via substring matching
- Fix respRetryAfter called before nil guard in doWithRetry retry loop
- Use GetBool instead of string comparison for flag resolution
2026-04-01 09:32:59 +08:00
fantiu 7dbef98dd0 Merge pull request #58 from fantiu/feat-login-upgrade
Feat login upgrade
2026-04-01 00:53:52 +08:00
fantiu 773804ee80 feat(auth): enhance device flow with CLI auth check and admin guidance 2026-04-01 00:47:49 +08:00
fantiu 1df56cc99b Merge branch 'DingTalk-Real-AI:main' into main 2026-03-31 21:06:17 +08:00
coffeeBigSir 0606762c29 Merge pull request #51 from Gyyshen/feature/addskill
feature: add skill management command for downloading and installing skills
2026-03-31 19:29:20 +08:00
meng93 e7677df541 Merge pull request #53 from DingTalk-Real-AI/feat/support-issue-notification
feat: 支持issuie消息通知
2026-03-31 18:58:47 +08:00
meng93 dda4dacb1c feat: to #73551688 支持消息通知 2026-03-31 17:38:08 +08:00
shenyunliang 345156c605 fix: correct import path for errors package in skill_command.go 2026-03-31 16:21:22 +08:00
github-actions[bot] 75b873d231 chore: update coverage badge [skip ci] 2026-03-31 05:47:53 +00:00
coffeeBigSir a912cbc52b Merge pull request #43 from audanye-sudo/feat/stability-and-hint-optimization
docs: update DingTalk co-creation group QR code
2026-03-31 10:03:38 +08:00
audanye-sudo 5a99b84c25 docs: update DingTalk co-creation group QR code image 2026-03-31 10:00:56 +08:00
audanye-sudoandClaude Opus 4.6 7fda120d5a feat: enhance error diagnostics with trace ID, server error code, and comprehensive logging
Improve CLI error output and local logging to enable offline issue diagnosis:

- Add ServerDiagnostics struct to extract and propagate trace_id, server_error_code,
  technical_detail, and server_retryable from MCP server responses
- Extract diagnostics from JSON-RPC error.data, tool call result content, and HTTP
  response headers (X-Trace-Id, X-Request-Id, x-dingtalk-trace-id)
- Redesign PrintHuman with three verbosity levels (Normal/Verbose/Debug):
  Normal shows trace ID + server code; Verbose adds technical detail;
  Debug adds internal diagnostics (RPC code, operation, reason)
- Add PrintHumanAt for explicit verbosity control, PrintHuman defaults to Normal
- Enhance local logging with request body (sanitized), response body (on error),
  retry attempts, and error classification events
- Add TruncateBody, SanitizeArguments, RedactHeaders logging utilities with
  sensitive key detection via substring matching
- Fix respRetryAfter called before nil guard in doWithRetry retry loop
- Use GetBool instead of string comparison for flag resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:01:41 +08:00
fantiu 3b8233e6ba Merge pull request #40 from fantiu/feat-readme-typo
docs(readme): combine redirect URLs into single line for easier copy-pasteFeat readme typo
2026-03-30 16:08:15 +08:00
fantiu 675ce53c06 Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-readme-typo
docs(readme): combine redirect URLs into single line for easier copy-paste
2026-03-30 16:01:32 +08:00
fantiu d51c1ff131 docs(readme): combine redirect URLs into single line for easier copy-paste 2026-03-30 16:01:03 +08:00
fantiu 8b423b97e8 Merge pull request #39 from fantiu/feat-performance
perf(auth): cache resolved credentials to avoid repeated keychain access
2026-03-30 15:22:15 +08:00
fantiu 1a6d129fe3 调整回调地址格式 2026-03-30 15:17:56 +08:00
fantiu a0bf715ddf Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-performance
解决Windows issue bug,修改readme回调地址格式
2026-03-30 15:11:29 +08:00
github-actions[bot] d2c5a027d4 chore: update coverage badge [skip ci] 2026-03-30 07:09:07 +00:00
fantiu 077665e27b Merge branch 'DingTalk-Real-AI:main' into main 2026-03-30 15:08:10 +08:00
coffeeBigSir 5c41d2b8f4 Merge pull request #29 from DingTalk-Real-AI/xtyh
Xtyh
2026-03-30 14:55:46 +08:00
tianlei.qjb 7d9e592f84 去除message history 2026-03-30 14:49:30 +08:00
tianlei.qjb 11199e6848 add interactive confirmation for destructive dynamic commands 2026-03-30 14:23:50 +08:00
tianlei.qjb 19c38d4b94 add report helper with flexible date parsing and defaults 2026-03-30 13:44:01 +08:00
tianlei.qjb 851cf43180 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-30 13:32:31 +08:00
github-actions[bot] e004df38c7 chore: update coverage badge [skip ci] 2026-03-30 04:01:24 +00:00
fantiu 3bb504bb1f feat(auth): persist client credentials and optimize keychain access
- Auto-persist client-id/client-secret after successful login (stored in keychain)
- Enhanced logout to clean up app.json, keychain secrets, and token.json
- Fixed OAuth callback race condition (write response before sending code)
- Added credentials cache to avoid repeated keychain access (improves CLI speed)
- Updated README with credential priority docs and bumped to v1.0.5
2026-03-30 11:59:03 +08:00
fantiu 26263f8a17 feat(auth): persist client credentials and optimize keychain access
- Auto-persist client-id/client-secret after successful login (stored in keychain)
- Enhanced logout to clean up app.json, keychain secrets, and token.json
- Fixed OAuth callback race condition (write response before sending code)
- Added credentials cache to avoid repeated keychain access (improves CLI speed)
- Updated README with credential priority docs and bumped to v1.0.4
2026-03-30 11:57:07 +08:00
tianlei.qjb 3c83c0cff2 Merge branch 'xtyh' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-29 23:57:09 +08:00
tianlei.qjb 4b555abefe docs: remove v1.0.1 version tags from feature sections 2026-03-29 23:56:35 +08:00
tianlei.qjb 4742112000 feature modified 2026-03-29 23:49:18 +08:00
tianlei.qjb 7f0567aa39 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-29 23:48:02 +08:00
tianlei.qjb 54145b65ec fix test script 2026-03-29 22:30:03 +08:00
tianlei.qjb 2fad9c95db test 2026-03-29 22:29:15 +08:00
40 changed files with 4034 additions and 145 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 53.9%"><title>coverage: 53.9%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">53.9%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">53.9%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+52
View File
@@ -0,0 +1,52 @@
# Issue 变更推送到 Webhook
# 当有 Issue 变更时,发送指定格式的数据到 webhook
name: 📤 Issue Webhook Notification
on:
issues:
types: [opened, reopened, closed, edited, labeled, unlabeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: 📬 Send Issue to Webhook
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.ISSUE_WEBHOOK_URL;
if (!webhook) {
console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification');
return;
}
const payload = context.payload;
const issue = payload.issue;
const action = payload.action;
// 构建指定格式的数据
const webhookPayload = {
action: action,
issue: {
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state,
html_url: issue.html_url
}
};
const response = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(webhookPayload)
});
if (response.ok) {
console.log('✅ Webhook notification sent successfully');
} else {
console.log('❌ Failed to send webhook notification:', response.status, response.statusText);
}
env:
ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }}
+49
View File
@@ -0,0 +1,49 @@
# Issue 自动同步到钉钉群
# 当有新 Issue 时,自动发送到钉钉群(不包括 comment)
name: 🔔 Issue Notification to DingTalk
on:
issues:
types: [opened, reopened, closed, labeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: 📬 Send Issue to DingTalk
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
return;
}
const payload = context.payload;
const issue = payload.issue;
const action = payload.action;
// 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式)
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
const content = issue.body?.substring(0, 500) || 'No description';
const url = issue.html_url;
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
const message = {
msgtype: 'markdown',
markdown: {
title: 'GitHub Issue 通知',
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
}
};
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
console.log('✅ DingTalk notification sent');
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
+6
View File
@@ -38,6 +38,12 @@ jobs:
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
- 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
with:
+1
View File
@@ -69,3 +69,4 @@ release:
draft: false
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
+14 -9
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/badge/release-v1.0.4-red" alt="v1.0.4"></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>
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **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/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
> <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>
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -39,6 +39,7 @@
</details>
---
<h2 id="why-dws">Why dws?</h2>
@@ -66,6 +67,11 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
> ```bash
> xattr -d com.apple.quarantine /path/to/dws
> ```
**Build from source**:
```bash
@@ -97,8 +103,7 @@ Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%
Go to app settings → **Security Settings**. Add the following redirect URLs and save:
```
http://127.0.0.1
https://login.dingtalk.com
http://127.0.0.1,https://login.dingtalk.com
```
> `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.
@@ -254,7 +259,7 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## Features
<details>
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes <code>v1.0.1</code></summary>
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
Built-in pipeline engine that normalizes flag names, splits sticky arguments, and fuzzy-matches typos:
@@ -283,7 +288,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
</details>
<details>
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption <code>v1.0.1</code></summary>
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption</summary>
```bash
# Built-in jq expressions
@@ -297,7 +302,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls <code>v1.0.1</code></summary>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
```bash
dws schema # list all products and tools
@@ -309,7 +314,7 @@ dws schema --jq '.products[].id' # extract all product ID
</details>
<details>
<summary><strong>Pipe & File Input</strong> — read flag values from files or stdin <code>v1.0.1</code></summary>
<summary><strong>Pipe & File Input</strong> — read flag values from files or stdin</summary>
```bash
# Read message body from a file
@@ -332,7 +337,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 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, message history, topic replies, send as user |
| 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 |
+14 -9
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/badge/release-v1.0.4-red" alt="v1.0.4"></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>
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 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/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
> <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>
<details>
<summary><strong>目录</strong></summary>
@@ -39,6 +39,7 @@
</details>
---
<h2 id="why-dws">为什么选择 dws?</h2>
@@ -66,6 +67,11 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
> ```bash
> xattr -d com.apple.quarantine /path/to/dws
> ```
**从源码构建**:
```bash
@@ -97,8 +103,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
```
http://127.0.0.1
https://login.dingtalk.com
http://127.0.0.1,https://login.dingtalk.com
```
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
@@ -254,7 +259,7 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## 功能特性
<details>
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误 <code>v1.0.1</code></summary>
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
内置 Pipeline 纠错引擎,支持命名风格转换、粘连参数拆分、拼写模糊匹配:
@@ -283,7 +288,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
</details>
<details>
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗 <code>v1.0.1</code></summary>
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗</summary>
```bash
# 内置 jq 表达式
@@ -297,7 +302,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构 <code>v1.0.1</code></summary>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
```bash
dws schema # 列出所有产品和工具
@@ -309,7 +314,7 @@ dws schema --jq '.products[].id' # 提取所有产品 ID
</details>
<details>
<summary><strong>管道 & 文件输入</strong> — 从文件或 stdin 读取 flag 值 <code>v1.0.1</code></summary>
<summary><strong>管道 & 文件输入</strong> — 从文件或 stdin 读取 flag 值</summary>
```bash
# 从文件读取消息内容
@@ -332,7 +337,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 服务 | 命令 | 工具数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、消息拉取、话题回复、以用户身份发消息 |
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 |
| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
+38 -4
View File
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
@@ -59,6 +60,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
}
}()
store := cacheStoreFromEnv()
partition := config.DefaultPartition
@@ -70,13 +77,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
}
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
}
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
@@ -92,9 +106,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
}
slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
}
fetchStart := time.Now()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
}
if fetchErr != nil {
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
@@ -106,12 +126,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
} else {
servers = market.NormalizeServers(resp, "market")
slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers))
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
}
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
}
}
}
}
@@ -122,9 +148,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
}
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds))
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
}
return cmds
}
+35 -1
View File
@@ -51,19 +51,34 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
}
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
initStart := time.Now()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
}
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
execStart := time.Now()
executed, err := root.ExecuteC()
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
}
if err != nil {
if executed == nil {
executed = root
@@ -117,7 +132,21 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro
if wantsJSONErrors(root) {
return apperrors.PrintJSON(stdout, err)
}
return apperrors.PrintHuman(stderr, err)
return apperrors.PrintHumanAt(stderr, err, resolveVerbosity(root))
}
// resolveVerbosity derives the error verbosity level from the root command's flags.
func resolveVerbosity(cmd *cobra.Command) apperrors.Verbosity {
if cmd == nil {
return apperrors.VerbosityNormal
}
if debug, err := cmd.Flags().GetBool("debug"); err == nil && debug {
return apperrors.VerbosityDebug
}
if verbose, err := cmd.Flags().GetBool("verbose"); err == nil && verbose {
return apperrors.VerbosityVerbose
}
return apperrors.VerbosityNormal
}
func wantsJSONErrors(root *cobra.Command) bool {
@@ -236,6 +265,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(),
newSkillCommand(),
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
@@ -261,6 +291,10 @@ func newAuthCommand() *cobra.Command {
return buildAuthCommand()
}
func newSkillCommand() *cobra.Command {
return buildSkillCommand()
}
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
+111 -19
View File
@@ -22,6 +22,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
@@ -74,6 +75,13 @@ type runtimeRunner struct {
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
}
}()
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -117,7 +125,17 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
// Lazy bind FileLogger: it may be nil at construction time because
// configureLogLevel runs later in PersistentPreRunE.
if r.transport.FileLogger == nil {
r.transport.FileLogger = FileLoggerInstance()
}
authStart := time.Now()
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
}
if invocation.DryRun {
return executor.Result{
@@ -148,19 +166,26 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
callStart := time.Now()
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
}
if err != nil {
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return executor.Result{}, mcpErr
@@ -172,11 +197,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
}
@@ -203,25 +231,56 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
configDir := defaultConfigDir()
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
configureOAuthProviderCompatibility(provider, configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token)
}
// If the error is a decryption failure (corrupted data), surface
// it immediately instead of falling back to empty token.
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token)
}
return ""
// Use cached token to avoid repeated Keychain access (~70ms per call)
return getCachedRuntimeToken(ctx)
}
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
}
}()
configDir := defaultConfigDir()
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
configureOAuthProviderCompatibility(provider, configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
cachedRuntimeToken = strings.TrimSpace(token)
return
}
// If the error is a decryption failure (corrupted data), log and bail out
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
// Try legacy manager as fallback
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
cachedRuntimeToken = strings.TrimSpace(token)
return
}
})
return cachedRuntimeToken
}
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
}
func newRuntimeContentScanner() safety.Scanner {
@@ -328,3 +387,36 @@ func extractMCPErrorMessage(result transport.ToolCallResult) string {
}
return "MCP tool returned an error response"
}
// logBusinessError logs MCP tool errors and business errors to the file logger
// so they can be diagnosed offline. These errors arrive as HTTP 200 responses
// and would otherwise not be captured by transport-level logging.
func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) {
if logger == nil {
return
}
attrs := []any{
"product", inv.CanonicalProduct,
"tool", inv.Tool,
"reason", reason,
}
if diag.TraceID != "" {
attrs = append(attrs, "trace_id", diag.TraceID)
}
if diag.ServerErrorCode != "" {
attrs = append(attrs, "server_error_code", diag.ServerErrorCode)
}
if diag.TechnicalDetail != "" {
attrs = append(attrs, "technical_detail", diag.TechnicalDetail)
}
if msg, ok := content["error"].(string); ok {
attrs = append(attrs, "error", msg)
}
if msg, ok := content["errorMsg"].(string); ok {
attrs = append(attrs, "errorMsg", msg)
}
if msg, ok := content["message"].(string); ok {
attrs = append(attrs, "message", msg)
}
logger.Warn("business_error", attrs...)
}
+377
View File
@@ -0,0 +1,377 @@
// 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 (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
const (
// skillDownloadEndpoint is the API endpoint for downloading skills.
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
skillDownloadTimeout = 5 * time.Minute
)
// downloadSkillResponse represents the API response for skill download.
type downloadSkillResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result *downloadSkillResult `json:"result,omitempty"`
}
// downloadSkillResult contains the download URL and file name.
type downloadSkillResult struct {
DownloadURL string `json:"downloadUrl"`
FileName string `json:"fileName"`
}
// agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory.
var agentSkillPaths = map[string]string{
"qoder": ".qoder/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
}
// supportedTargets returns a comma-separated list of supported targets.
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
targets = append(targets, ".")
return strings.Join(targets, ", ")
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
Short: "技能管理",
Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newSkillAddCommand())
return cmd
}
func newSkillAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <skillId> <target>",
Short: "下载并安装技能到指定目录",
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
参数:
skillId 技能 ID(必填),可从钉钉技能市场获取
target 安装目标(必填),支持: %s
安装路径:
qoder -> ~/.qoder/skills/
claude -> ~/.claude/skills/
cursor -> ~/.cursor/skills/
codex -> ~/.codex/skills/
opencode -> ~/.config/opencode/skills/
. -> 当前目录
示例:
dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill add skill-123 claude # 安装到 ~/.claude/skills/
dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: runSkillAdd,
}
return cmd
}
func runSkillAdd(cmd *cobra.Command, args []string) error {
skillID := strings.TrimSpace(args[0])
target := strings.TrimSpace(args[1])
if skillID == "" {
return apperrors.NewValidation("skillId is required")
}
// Resolve target path
destPath, err := resolveSkillTargetPath(target)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
// Load auth token
configDir := defaultConfigDir()
tokenData, err := authpkg.LoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
apperrors.WithHint("请先执行 'dws auth login' 登录"),
apperrors.WithActions("dws auth login"))
}
ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
defer cancel()
w := cmd.OutOrStdout()
// Step 1: Get download URL from API
fmt.Fprintf(w, "正在获取技能信息...\n")
downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID)
if err != nil {
return err
}
if !downloadResp.Success {
errMsg := downloadResp.ErrorMsg
if errMsg == "" {
errMsg = downloadResp.ErrorCode
}
if errMsg == "" {
errMsg = "unknown error"
}
return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg),
apperrors.WithReason(downloadResp.ErrorCode))
}
if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" {
return apperrors.NewAPI("skill download URL not found in response")
}
// Step 2: Download the skill zip file
fmt.Fprintf(w, "正在下载技能...\n")
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
if err != nil {
return err
}
defer cleanupTempFile(tempZipPath)
// Step 3: Extract zip to destination
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
if err := extractSkillZip(tempZipPath, destPath); err != nil {
return err
}
fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
fmt.Fprintf(w, "安装路径: %s\n", destPath)
return nil
}
// resolveSkillTargetPath resolves the target argument to an absolute path.
func resolveSkillTargetPath(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("target is required")
}
// Special case: current directory
if target == "." {
return os.Getwd()
}
// Look up predefined agent paths
relPath, ok := agentSkillPaths[strings.ToLower(target)]
if !ok {
return "", fmt.Errorf("unsupported target")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, relPath), nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token",
apperrors.WithHint("请执行 'dws auth login' 重新登录"),
apperrors.WithActions("dws auth login"))
}
if resp.StatusCode != http.StatusOK {
return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err))
}
var result downloadSkillResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err))
}
return &result, nil
}
// downloadSkillFile downloads the skill zip file to a temporary location.
func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err))
}
client := &http.Client{Timeout: skillDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
// Create temp file
if fileName == "" {
fileName = "skill.zip"
}
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
}
tempPath := tempFile.Name()
// Copy response body to temp file
_, err = io.Copy(tempFile, resp.Body)
closeErr := tempFile.Close()
if err != nil {
os.Remove(tempPath)
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
}
if closeErr != nil {
os.Remove(tempPath)
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
}
return tempPath, nil
}
// extractSkillZip extracts a zip file to the destination directory.
func extractSkillZip(zipPath, destDir string) error {
// Ensure destination directory exists
if err := os.MkdirAll(destDir, 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err))
}
defer reader.Close()
for _, file := range reader.File {
if err := extractZipFile(file, destDir); err != nil {
return err
}
}
return nil
}
// extractZipFile extracts a single file from the zip archive.
func extractZipFile(file *zip.File, destDir string) error {
// Sanitize file path to prevent zip slip attacks
filePath := filepath.Join(destDir, file.Name)
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) {
return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name))
}
if file.FileInfo().IsDir() {
// Use 0755 to ensure we have write permission for creating files inside
return os.MkdirAll(filePath, 0755)
}
// Ensure parent directory exists with write permission
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
}
// Extract file
srcFile, err := file.Open()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
}
defer srcFile.Close()
// Use file mode from zip but ensure at least 0644 for files
fileMode := file.Mode()
if fileMode&0600 == 0 {
fileMode = 0644
}
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
}
defer destFile.Close()
if _, err := io.Copy(destFile, srcFile); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
}
return nil
}
// cleanupTempFile removes a temporary file, ignoring errors.
func cleanupTempFile(path string) {
if path != "" {
os.Remove(path)
}
}
+732
View File
@@ -0,0 +1,732 @@
// 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 (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
func TestResolveSkillTargetPath(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
tests := []struct {
name string
target string
wantSuffix string
wantErr bool
}{
{
name: "qoder target",
target: "qoder",
wantSuffix: filepath.Join(".qoder", "skills"),
wantErr: false,
},
{
name: "claude target",
target: "claude",
wantSuffix: filepath.Join(".claude", "skills"),
wantErr: false,
},
{
name: "cursor target",
target: "cursor",
wantSuffix: filepath.Join(".cursor", "skills"),
wantErr: false,
},
{
name: "codex target",
target: "codex",
wantSuffix: filepath.Join(".codex", "skills"),
wantErr: false,
},
{
name: "opencode target",
target: "opencode",
wantSuffix: filepath.Join(".config", "opencode", "skills"),
wantErr: false,
},
{
name: "case insensitive - QODER",
target: "QODER",
wantSuffix: filepath.Join(".qoder", "skills"),
wantErr: false,
},
{
name: "case insensitive - Claude",
target: "Claude",
wantSuffix: filepath.Join(".claude", "skills"),
wantErr: false,
},
{
name: "invalid target",
target: "invalid",
wantErr: true,
},
{
name: "empty target",
target: "",
wantErr: true,
},
{
name: "whitespace only",
target: " ",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveSkillTargetPath(tt.target)
if (err != nil) != tt.wantErr {
t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
expected := filepath.Join(homeDir, tt.wantSuffix)
if got != expected {
t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected)
}
}
})
}
}
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
// Test "." target returns current working directory
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
got, err := resolveSkillTargetPath(".")
if err != nil {
t.Errorf("resolveSkillTargetPath(\".\") error = %v", err)
return
}
if got != cwd {
t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd)
}
}
func TestParseDownloadSkillResponse(t *testing.T) {
tests := []struct {
name string
jsonInput string
wantSuccess bool
wantURL string
wantFile string
wantErrCode string
wantErrMsg string
}{
{
name: "successful response",
jsonInput: `{
"success": true,
"result": {
"downloadUrl": "https://example.com/skill.zip",
"fileName": "my-skill.zip"
}
}`,
wantSuccess: true,
wantURL: "https://example.com/skill.zip",
wantFile: "my-skill.zip",
},
{
name: "error response",
jsonInput: `{
"success": false,
"errorCode": "SKILL_NOT_FOUND",
"errorMsg": "The skill does not exist"
}`,
wantSuccess: false,
wantErrCode: "SKILL_NOT_FOUND",
wantErrMsg: "The skill does not exist",
},
{
name: "success with empty result",
jsonInput: `{
"success": true,
"result": null
}`,
wantSuccess: true,
wantURL: "",
wantFile: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp downloadSkillResponse
if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil {
t.Fatalf("failed to unmarshal JSON: %v", err)
}
if resp.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess)
}
if tt.wantSuccess && resp.Result != nil {
if resp.Result.DownloadURL != tt.wantURL {
t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL)
}
if resp.Result.FileName != tt.wantFile {
t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile)
}
}
if !tt.wantSuccess {
if resp.ErrorCode != tt.wantErrCode {
t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode)
}
if resp.ErrorMsg != tt.wantErrMsg {
t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg)
}
}
})
}
}
func TestExtractSkillZip(t *testing.T) {
// Create a temporary zip file with test content
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "test.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create zip file with test content
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Add a file to the zip
fileContent := []byte("test content")
writer, err := zipWriter.Create("test-file.txt")
if err != nil {
t.Fatalf("failed to create file in zip: %v", err)
}
if _, err := writer.Write(fileContent); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
// Add a subdirectory with a file
writer, err = zipWriter.Create("subdir/nested-file.txt")
if err != nil {
t.Fatalf("failed to create nested file in zip: %v", err)
}
if _, err := writer.Write([]byte("nested content")); err != nil {
t.Fatalf("failed to write nested file content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract the zip
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Fatalf("extractSkillZip() error = %v", err)
}
// Verify extracted files
extractedFile := filepath.Join(destDir, "test-file.txt")
content, err := os.ReadFile(extractedFile)
if err != nil {
t.Errorf("failed to read extracted file: %v", err)
}
if string(content) != "test content" {
t.Errorf("extracted content = %v, want %v", string(content), "test content")
}
// Verify nested file
nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt")
content, err = os.ReadFile(nestedFile)
if err != nil {
t.Errorf("failed to read nested file: %v", err)
}
if string(content) != "nested content" {
t.Errorf("nested content = %v, want %v", string(content), "nested content")
}
}
func TestExtractSkillZipPreventZipSlip(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "malicious.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create a zip file with a path traversal attempt
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Try to create a file with path traversal
writer, err := zipWriter.Create("../../../etc/passwd")
if err != nil {
t.Fatalf("failed to create malicious file in zip: %v", err)
}
if _, err := writer.Write([]byte("malicious content")); err != nil {
t.Fatalf("failed to write malicious content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract should fail due to zip slip protection
err = extractSkillZip(zipPath, destDir)
if err == nil {
t.Error("extractSkillZip() should have failed for zip slip attack")
}
if !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("error should mention invalid file path, got: %v", err)
}
}
func TestSkillAddCommandValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
errMsg string
}{
{
name: "missing arguments",
args: []string{"skill", "add"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
{
name: "missing target",
args: []string{"skill", "add", "skill-123"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
{
name: "too many arguments",
args: []string{"skill", "add", "skill-123", "qoder", "extra"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs(tt.args)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err := cmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("error = %v, should contain %v", err, tt.errMsg)
}
})
}
}
func TestSkillAddInvalidTarget(t *testing.T) {
// Setup: Create config directory with valid token
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
// Save a valid token
err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
})
if err != nil {
t.Fatalf("failed to save token data: %v", err)
}
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err = cmd.Execute()
if err == nil {
t.Error("Execute() should have failed for invalid target")
}
if !strings.Contains(err.Error(), "invalid target") {
t.Errorf("error should mention invalid target, got: %v", err)
}
}
func TestSkillAddRequiresAuth(t *testing.T) {
// Setup: Create config directory without token
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
// Ensure the config directory exists but has no token
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err := cmd.Execute()
if err == nil {
t.Error("Execute() should have failed without auth")
}
// Check for authentication-related error (English or Chinese)
errStr := err.Error()
if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") {
t.Errorf("error should mention authentication, got: %v", err)
}
}
func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
// Create mock server that returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
// We can't easily test the actual fetchSkillDownloadInfo function
// because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestSupportedTargets(t *testing.T) {
targets := supportedTargets()
// Should contain all predefined targets
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
for _, expected := range expectedTargets {
if !strings.Contains(targets, expected) {
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
}
}
}
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
// Verify that paths use platform-appropriate separators
for target, path := range agentSkillPaths {
if runtime.GOOS == "windows" {
if strings.Contains(path, "/") && !strings.Contains(path, "\\") {
// On Windows, filepath.Join should use backslashes
// But raw map values may use forward slashes
t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path)
}
}
// Test that resolveSkillTargetPath produces valid paths
resolved, err := resolveSkillTargetPath(target)
if err != nil {
t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err)
continue
}
// Path should be absolute
if !filepath.IsAbs(resolved) {
t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved)
}
}
}
func TestCleanupTempFile(t *testing.T) {
// Create a temporary file
tempFile, err := os.CreateTemp("", "test-cleanup-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempPath := tempFile.Name()
tempFile.Close()
// Verify file exists
if _, err := os.Stat(tempPath); os.IsNotExist(err) {
t.Fatalf("temp file should exist before cleanup")
}
// Clean up
cleanupTempFile(tempPath)
// Verify file is deleted
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
t.Errorf("temp file should be deleted after cleanup")
}
// Cleanup should not panic on empty path
cleanupTempFile("")
// Cleanup should not panic on non-existent file
cleanupTempFile("/nonexistent/path/file.txt")
}
func TestDownloadSkillResponseJSON(t *testing.T) {
// Test JSON marshaling/unmarshaling round-trip
original := downloadSkillResponse{
Success: true,
Result: &downloadSkillResult{
DownloadURL: "https://example.com/skill.zip",
FileName: "skill.zip",
},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed downloadSkillResponse
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Success != original.Success {
t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success)
}
if parsed.Result.DownloadURL != original.Result.DownloadURL {
t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL)
}
if parsed.Result.FileName != original.Result.FileName {
t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName)
}
}
func TestSkillCommandHelp(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "--help"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
output := out.String()
// Check for the Long description which is shown in help
if !strings.Contains(output, "技能") {
t.Errorf("help should mention '技能', got: %s", output)
}
if !strings.Contains(output, "add") {
t.Errorf("help should mention 'add' subcommand, got: %s", output)
}
}
func TestSkillAddCommandHelp(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "--help"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
output := out.String()
// Should mention supported targets
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"}
for _, target := range expectedTargets {
if !strings.Contains(output, target) {
t.Errorf("help should mention target '%s', got: %s", target, output)
}
}
}
func TestDownloadSkillFileSuccess(t *testing.T) {
// Create a mock server that returns a zip file
expectedContent := []byte("fake zip content")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK)
w.Write(expectedContent)
}))
defer server.Close()
// Download the file
ctx := context.Background()
tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip")
if err != nil {
t.Fatalf("downloadSkillFile() error = %v", err)
}
defer os.Remove(tempPath)
// Verify the downloaded content
content, err := os.ReadFile(tempPath)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if !bytes.Equal(content, expectedContent) {
t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent)
}
}
func TestDownloadSkillFileServerError(t *testing.T) {
// Create a mock server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ctx := context.Background()
_, err := downloadSkillFile(ctx, server.URL, "test.zip")
if err == nil {
t.Error("downloadSkillFile() should fail on server error")
}
}
func TestExtractSkillZipEmptyZip(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "empty.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create an empty zip file
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract should succeed even for empty zip
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Errorf("extractSkillZip() should not fail for empty zip: %v", err)
}
// Destination directory should be created
if _, err := os.Stat(destDir); os.IsNotExist(err) {
t.Errorf("destination directory should be created")
}
}
func TestExtractSkillZipWithDirectories(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "test.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create zip with directory entries
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Add a directory entry with proper permissions
header := &zip.FileHeader{
Name: "mydir/",
Method: zip.Deflate,
}
header.SetMode(0755 | os.ModeDir)
_, err = zipWriter.CreateHeader(header)
if err != nil {
t.Fatalf("failed to create directory in zip: %v", err)
}
// Add a file in the directory
fileHeader := &zip.FileHeader{
Name: "mydir/file.txt",
Method: zip.Deflate,
}
fileHeader.SetMode(0644)
writer, err := zipWriter.CreateHeader(fileHeader)
if err != nil {
t.Fatalf("failed to create file in zip: %v", err)
}
if _, err := writer.Write([]byte("content")); err != nil {
t.Fatalf("failed to write content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Fatalf("extractSkillZip() error = %v", err)
}
// Verify directory was created
dirPath := filepath.Join(destDir, "mydir")
info, err := os.Stat(dirPath)
if err != nil {
t.Errorf("directory should exist: %v", err)
} else if !info.IsDir() {
t.Errorf("mydir should be a directory")
}
// Verify file exists
filePath := filepath.Join(destDir, "mydir", "file.txt")
content, err := os.ReadFile(filePath)
if err != nil {
t.Errorf("file should exist: %v", err)
} else if string(content) != "content" {
t.Errorf("file content mismatch: got %s, want 'content'", string(content))
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
package app
var version = "v1.0.2"
var version = "v1.0.6"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
+46 -8
View File
@@ -45,6 +45,14 @@ var (
cachedAppConfigMu sync.RWMutex
)
// Cached resolved credentials (avoid repeated keychain access).
var (
cachedResolvedID string
cachedResolvedSecret string
cachedResolvedValid bool
cachedResolvedMu sync.RWMutex
)
// GetAppConfigPath returns the path to the app config file.
func GetAppConfigPath(configDir string) string {
return filepath.Join(configDir, appConfigFile)
@@ -103,6 +111,13 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
cachedAppConfig = config
cachedAppConfigMu.Unlock()
// Invalidate resolved credentials cache so next access re-resolves
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.Unlock()
return nil
}
@@ -125,6 +140,13 @@ func DeleteAppConfig(configDir string) error {
cachedAppConfig = nil
cachedAppConfigMu.Unlock()
// Clear resolved credentials cache
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.Unlock()
return nil
}
@@ -169,17 +191,33 @@ func HasAppConfig(configDir string) bool {
}
// ResolveAppCredentials resolves the client ID and secret from the app config.
// Results are cached to avoid repeated keychain access.
// Returns empty strings if the config doesn't exist or resolution fails.
func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cfg := GetCachedAppConfig(configDir)
if cfg == nil {
return "", ""
// Fast path: check cache first
cachedResolvedMu.RLock()
if cachedResolvedValid {
id, secret := cachedResolvedID, cachedResolvedSecret
cachedResolvedMu.RUnlock()
return id, secret
}
cachedResolvedMu.RUnlock()
// Slow path: load and cache
cachedResolvedMu.Lock()
defer cachedResolvedMu.Unlock()
// Double-check after acquiring write lock
if cachedResolvedValid {
return cachedResolvedID, cachedResolvedSecret
}
clientID = cfg.ClientID
secret, err := ResolveSecret(cfg.ClientSecret)
if err == nil {
clientSecret = secret
cfg := GetCachedAppConfig(configDir)
if cfg != nil {
cachedResolvedID = cfg.ClientID
if secret, err := ResolveSecret(cfg.ClientSecret); err == nil {
cachedResolvedSecret = secret
}
}
return clientID, clientSecret
cachedResolvedValid = true
return cachedResolvedID, cachedResolvedSecret
}
+13 -2
View File
@@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) {
func TestClientID_EnvFallback(t *testing.T) {
SetClientID("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "env-id")
if got := ClientID(); got != "env-id" {
t.Fatalf("expected env-id, got %s", got)
@@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) {
func TestClientID_Default(t *testing.T) {
SetClientID("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "")
if got := ClientID(); got != DefaultClientID {
t.Fatalf("expected default, got %s", got)
// When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string
if strings.HasPrefix(DefaultClientID, "<") {
if got := ClientID(); got != "" {
t.Fatalf("expected empty string for placeholder, got %s", got)
}
} else {
if got := ClientID(); got != DefaultClientID {
t.Fatalf("expected default, got %s", got)
}
}
}
@@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) {
func TestClientSecret_EnvFallback(t *testing.T) {
SetClientSecret("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "env-secret")
if got := ClientSecret(); got != "env-secret" {
t.Fatalf("expected env-secret, got %s", got)
@@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) {
func TestClientSecret_Default(t *testing.T) {
SetClientSecret("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "")
if got := ClientSecret(); got != DefaultClientSecret {
t.Fatalf("expected default, got %s", got)
+54
View File
@@ -96,6 +96,23 @@ type serviceResult struct {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
// Ensure we have a valid client ID (fetch from MCP if not available)
if p.clientID == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
// Mark that clientID is from MCP
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
}
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenData, err := p.loginOnce(ctx, attempt)
@@ -150,6 +167,43 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Check if CLI auth is enabled for this organization
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
if authErr != nil {
if p.logger != nil {
p.logger.Warn("failed to check CLI auth status", "error", authErr)
}
// Continue anyway - fail open for better UX
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
// CLI auth is disabled - show detailed error with admin info
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
// Try to get super admin list
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
// Show up to 3 admins
maxAdmins := 3
if len(admins.Result) < maxAdmins {
maxAdmins = len(admins.Result)
}
var adminNames []string
for i := 0; i < maxAdmins; i++ {
adminNames = append(adminNames, admins.Result[i].Name)
}
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
}
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
}
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
+4
View File
@@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode
func TestRequestDeviceCodeSuccess(t *testing.T) {
t.Parallel()
// Set a test client ID
SetClientID("test-client-id")
t.Cleanup(func() { SetClientID("") })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
+86 -2
View File
@@ -16,6 +16,7 @@ package auth
import (
"os"
"path/filepath"
"strings"
"sync"
)
@@ -59,16 +60,86 @@ const (
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
// MCP OAuth endpoints (used when clientId is fetched from MCP).
MCPOAuthTokenPath = "/oauth2/getToken"
MCPRefreshTokenPath = "/oauth2/refreshToken"
MCPRevokeTokenPath = "/oauth2/revokeToken"
)
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for pre-release environment)
// 2. Default value (https://mcp.dingtalk.com)
func GetMCPBaseURL() string {
mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
if data, err := os.ReadFile(mcpURLPath); err == nil {
if url := strings.TrimSpace(string(data)); url != "" {
return url
}
}
return DefaultMCPBaseURL
}
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
clientIDFromMCP bool
)
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
func SetClientIDFromMCP(id string) {
clientMu.Lock()
defer clientMu.Unlock()
runtimeClientID = id
clientIDFromMCP = true
}
// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
func IsClientIDFromMCP() bool {
clientMu.RLock()
defer clientMu.RUnlock()
return clientIDFromMCP
}
// GetUserAccessTokenURL returns the appropriate token exchange URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetUserAccessTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPOAuthTokenPath
}
return UserAccessTokenURL
}
// GetRefreshTokenURL returns the appropriate token refresh URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetRefreshTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRefreshTokenPath
}
return UserAccessTokenURL // DingTalk uses same endpoint for refresh
}
// GetRevokeTokenURL returns the token revocation URL (MCP only).
// Returns empty string if not using MCP mode.
func GetRevokeTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRevokeTokenPath
}
return "" // Direct mode doesn't have revoke endpoint
}
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
@@ -87,7 +158,9 @@ func SetClientSecret(secret string) {
// 1. Runtime override (CLI flag --client-id)
// 2. Persisted app config (from previous login)
// 3. Environment variable (DWS_CLIENT_ID)
// 4. Default hardcoded value
// 4. Default hardcoded value (if not a placeholder)
// Returns empty string if no valid client ID is available.
// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
@@ -102,7 +175,11 @@ func ClientID() string {
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
return v
}
return DefaultClientID
// Only return default if it's not a placeholder
if !strings.HasPrefix(DefaultClientID, "<") {
return DefaultClientID
}
return ""
}
// ClientSecret returns the OAuth client secret with priority:
@@ -127,6 +204,13 @@ func ClientSecret() string {
return DefaultClientSecret
}
// HasValidClientSecret returns true if a valid client secret is available.
// A valid secret is one that is not a placeholder (e.g., <YOUR_CLIENT_SECRET>).
func HasValidClientSecret() bool {
secret := ClientSecret()
return secret != "" && !strings.HasPrefix(secret, "<")
}
// getRuntimeCredentials returns the runtime-override credentials if set.
// Returns empty strings if no runtime overrides were provided.
func getRuntimeCredentials() (clientID, clientSecret string) {
+429
View File
@@ -27,6 +27,11 @@ import (
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.exchangeCodeViaMCP(ctx, code)
}
// Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -40,7 +45,28 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
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) {
url := GetMCPBaseURL() + MCPOAuthTokenPath
body := map[string]string{
"clientId": ClientID(),
"authCode": code,
"grantType": "authorization_code",
}
resp, err := p.postJSON(ctx, url, body)
if err != nil {
return nil, err
}
return p.parseMCPTokenResponse(resp)
}
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.refreshViaMCP(ctx, data)
}
// Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -67,6 +93,34 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
return updated, nil
}
// refreshViaMCP refreshes token via MCP proxy.
func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
url := GetMCPBaseURL() + MCPRefreshTokenPath
body := map[string]string{
"clientId": ClientID(),
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
resp, err := p.postJSON(ctx, url, body)
if err != nil {
return nil, err
}
updated, err := p.parseMCPTokenResponse(resp)
if err != nil {
return nil, err
}
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if err := SaveTokenData(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
}
func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) ([]byte, error) {
b, err := json.Marshal(body)
if err != nil {
@@ -132,6 +186,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
var resp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
PersistentCode string `json:"persistentCode"`
ExpiresIn int64 `json:"expiresIn"`
CorpID string `json:"corpId"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body))
}
// Check for error response
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
}
if resp.AccessToken == "" {
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
}
now := time.Now()
expiresIn := resp.ExpiresIn
if expiresIn <= 0 {
expiresIn = config.DefaultAccessTokenExpiry
}
data := &TokenData{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
}
return data, nil
}
func buildAuthURL(clientID, redirectURI string) string {
params := url.Values{
"client_id": {clientID},
@@ -149,3 +245,336 @@ const successHTML = `<!DOCTYPE html>
.card{background:#fff;border-radius:12px;padding:40px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.08)}
h1{color:#1677ff;margin:0 0 8px}p{color:#666;margin:0}</style></head>
<body><div class="card"><h1>✅ 授权成功</h1><p>请返回终端继续操作。此页面可以关闭。</p></div></body></html>`
const notEnabledHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>钉钉 CLI</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;padding:20px}
.container{text-align:center;max-width:400px;width:100%}
.lock-icon{width:80px;height:100px;margin:0 auto 32px;position:relative}
.lock-body{width:60px;height:50px;background:linear-gradient(135deg,#1890ff 0%,#69c0ff 100%);
border-radius:8px;position:absolute;bottom:0;left:50%;transform:translateX(-50%);
box-shadow:0 8px 24px rgba(24,144,255,0.3)}
.lock-body::before{content:"";position:absolute;width:12px;height:12px;background:#fff;
border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
.lock-body::after{content:"";position:absolute;width:4px;height:12px;background:#fff;
top:50%;left:50%;transform:translateX(-50%);margin-top:4px;border-radius:2px}
.lock-shackle{width:36px;height:36px;border:6px solid #1890ff;border-bottom:none;
border-radius:20px 20px 0 0;position:absolute;top:8px;left:50%;transform:translateX(-50%)}
h1{font-size:22px;font-weight:600;color:#181c1f;margin-bottom:16px;line-height:1.4}
p{font-size:15px;color:#5f6368;line-height:1.6;margin-bottom:24px}
.form-group{text-align:left;margin-bottom:24px}
.form-label{font-size:14px;color:#181c1f;margin-bottom:8px;display:block}
.select-wrapper{position:relative}
select{width:100%;padding:12px 40px 12px 16px;font-size:15px;border:1px solid #d9d9d9;
border-radius:8px;background:#fff;appearance:none;cursor:pointer;color:#181c1f}
select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,255,0.1)}
.select-arrow{position:absolute;right:16px;top:50%;transform:translateY(-50%);pointer-events:none;
border:5px solid transparent;border-top:6px solid #5f6368}
.btn{display:block;width:100%;padding:14px 24px;background:linear-gradient(135deg,#1890ff 0%,#40a9ff 100%);
color:#fff;font-size:16px;font-weight:500;border-radius:24px;border:none;cursor:pointer;
box-shadow:0 4px 16px rgba(24,144,255,0.3);transition:all 0.2s}
.btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(24,144,255,0.4)}
.btn:disabled{background:#d9d9d9;box-shadow:none;cursor:not-allowed;transform:none}
.link{color:#1890ff;font-size:14px;text-decoration:none;margin-top:16px;display:inline-block}
.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: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}
@keyframes spin{to{transform:rotate(360deg)}}
</style></head>
<body><div class="container">
<div class="lock-icon"><div class="lock-shackle"></div><div class="lock-body"></div></div>
<h1>该组织尚未开启 CLI 数据访问权限</h1>
<p>你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。</p>
<div class="form-group">
<label class="form-label">你将使用以下账号授权登录</label>
<div class="select-wrapper">
<select id="adminSelect"><option value="">加载中...</option></select>
<div class="select-arrow"></div>
</div>
<div id="errorMsg" class="error-msg"></div>
</div>
<button id="applyBtn" class="btn" disabled>立即申请</button>
<a id="backLink" class="link" href="#">返回选择其他组织</a>
<div id="successMsg" class="success-msg">
<svg class="success-msg-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z" fill="#0066FF"/>
</svg>
<span class="success-msg-text">已向管理员发送权限申请,正在等待审核<br/>审核通过后,请返回终端继续操作</span>
</div>
</div>
<script>
const select=document.getElementById('adminSelect'),btn=document.getElementById('applyBtn'),
successMsg=document.getElementById('successMsg'),errorMsg=document.getElementById('errorMsg'),
backLink=document.getElementById('backLink');
let admins=[],clientId='',applySent=false,selectedAdminId='',pollTimer=null;
// Load status first, then load admins
async function init(){
try{
const statusRes=await fetch('/api/status');
const status=await statusRes.json();
clientId=status.clientId||'';
applySent=status.applySent||false;
selectedAdminId=status.selectedAdminId||'';
// Set back link URL
if(clientId){
const port=location.port;
const redirectUri=encodeURIComponent('http://127.0.0.1:'+port+'/callback');
backLink.href='https://login.dingtalk.com/oauth2/auth?client_id='+clientId+
'&prompt=consent&redirect_uri='+redirectUri+'&response_type=code&scope=openid+corpid';
}
// Restore state if apply was sent
if(applySent){
setAppliedState();
}
}catch(e){console.error('Failed to load status',e);}
loadAdmins();
}
function setAppliedState(){
btn.style.display='none';
select.disabled=true;
successMsg.style.display='flex';
backLink.style.pointerEvents='none';
backLink.style.color='#999';
backLink.onclick=function(e){e.preventDefault();return false;};
// Start polling for auth status
startPolling();
}
function startPolling(){
if(pollTimer)return;
pollTimer=setInterval(checkAuthStatus,5000);
// Also check immediately
checkAuthStatus();
}
async function checkAuthStatus(){
try{
const res=await fetch('/api/cliAuthEnabled');
const data=await res.json();
if(data.success&&data.result&&data.result.cliAuthEnabled){
// Auth enabled! Redirect to success page
stopPolling();
location.href='/success';
}
}catch(e){console.error('Poll error',e);
}
}
function stopPolling(){
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
}
async function loadAdmins(){
try{
const res=await fetch('/api/superAdmin');
const data=await res.json();
if(data.success&&data.result&&data.result.length>0){
admins=data.result;
select.innerHTML='<option value="">请选择</option>'+
admins.map(a=>'<option value="'+a.staffId+'">'+a.name+'</option>').join('');
// Restore selected admin if available
if(selectedAdminId){
select.value=selectedAdminId;
}
btn.disabled=!select.value;
}else{
select.innerHTML='<option value="">暂无可选管理员</option>';
showError(data.errorMsg||'获取管理员列表失败');
}
}catch(e){
select.innerHTML='<option value="">加载失败</option>';
showError('网络错误,请重试');
}
}
function showError(msg){errorMsg.textContent=msg;errorMsg.style.display='block';}
function hideError(){errorMsg.style.display='none';}
select.onchange=function(){btn.disabled=!this.value;hideError();};
btn.onclick=async function(){
if(!select.value)return;
btn.disabled=true;
btn.innerHTML='<span class="loading"></span>申请中...';
hideError();
try{
const res=await fetch('/api/sendApply?adminStaffId='+encodeURIComponent(select.value));
const data=await res.json();
if(data.success&&data.result){
setAppliedState();
}else{
showError(data.errorMsg||'申请失败,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
}catch(e){
showError('网络错误,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
};
init();
</script></body></html>`
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
type CLIAuthStatus struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
} `json:"result"`
}
// SuperAdmin represents a corp super admin.
type SuperAdmin struct {
StaffID string `json:"staffId"`
Name string `json:"name"`
}
// SuperAdminResponse represents the response from /cli/superAdmin API.
type SuperAdminResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result []SuperAdmin `json:"result"`
}
// SendApplyResponse represents the response from /cli/sendCliAuthApply API.
type SendApplyResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result bool `json:"result"`
}
// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp.
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
client := p.httpClient
if client == nil {
client = oauthHTTPClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var status CLIAuthStatus
if err := json.Unmarshal(data, &status); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &status, nil
}
// GetSuperAdmins fetches the list of corp super admins.
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var result SuperAdminResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &result, nil
}
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var result SendApplyResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &result, nil
}
// ClientIDResponse represents the response from /cli/clientId API.
type ClientIDResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result string `json:"result"`
}
// FetchClientIDFromMCP fetches the CLI client ID from MCP server.
// This is used when no client ID is provided via flags, config, or env vars.
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
var result ClientIDResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("parsing response: %w", err)
}
if !result.Success {
return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg)
}
return result.Result, nil
}
+262 -12
View File
@@ -15,6 +15,7 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"net"
"net/http"
"os"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
@@ -92,6 +94,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Fall through: full browser OAuth flow.
// Ensure we have a valid client ID (fetch from MCP if not available)
if p.clientID == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
// Mark that clientID is from MCP, so we use MCP OAuth endpoints
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
}
// Find a free port for the callback server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -100,15 +119,53 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
codeCh := make(chan string, 1)
// Channel to pass callback result (token data or error with CLI auth status)
type callbackResult struct {
token *TokenData
err error
cliAuthDisabled bool
}
resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1)
// Shared state for API handlers (protected by mutex)
var (
callbackToken *TokenData
callbackProcessed bool
callbackAuthDisabled bool
callbackApplySent bool // Whether apply request was sent
callbackSelectedAdminId string // Selected admin ID for apply
callbackTokenMu sync.Mutex
)
mux := http.NewServeMux()
mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) {
// Get code first to check if this is a new authorization or page refresh
code := r.URL.Query().Get("authCode")
if code == "" {
code = r.URL.Query().Get("code")
}
// Check if this is a page refresh (no code) and callback was already processed
callbackTokenMu.Lock()
if code == "" && 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
}
// Reset state for new authorization (user switched org)
if code != "" && callbackProcessed {
callbackProcessed = false
callbackApplySent = false
callbackSelectedAdminId = ""
}
callbackTokenMu.Unlock()
if code == "" {
select {
case errCh <- errors.New(i18n.T("回调中未收到授权码")):
@@ -118,14 +175,145 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码"))
return
}
select {
case codeCh <- code:
// Exchange code for token immediately in callback
tokenData, exchangeErr := p.exchangeCode(ctx, code)
if exchangeErr != nil {
// Check if we already have a processed state (authCode reused on refresh)
callbackTokenMu.Lock()
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()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, successHTML)
default:
// Select already exited (timeout/cancel); discard late callback.
w.WriteHeader(http.StatusGone)
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", exchangeErr.Error())
select {
case resultCh <- callbackResult{err: exchangeErr}:
default:
}
return
}
// Check CLI auth enabled status
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
// Store token and state for API handlers and refresh handling
callbackTokenMu.Lock()
callbackToken = tokenData
callbackProcessed = true
callbackAuthDisabled = cliAuthDisabled
callbackTokenMu.Unlock()
// Display appropriate HTML based on CLI auth status
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if cliAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
// Ensure response is flushed to client
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Notify main goroutine with full result
select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}:
default:
}
})
// API endpoint: get super admins
mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := GetSuperAdmins(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// API endpoint: send CLI auth apply
mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
adminStaffID := r.URL.Query().Get("adminStaffId")
if adminStaffID == "" {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`))
return
}
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
// Mark apply as sent and save selected admin on success
if result.Success && result.Result {
callbackTokenMu.Lock()
callbackApplySent = true
callbackSelectedAdminId = adminStaffID
callbackTokenMu.Unlock()
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// API endpoint: get current status (clientId, applySent, selectedAdminId)
mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
applySent := callbackApplySent
selectedAdminId := callbackSelectedAdminId
callbackTokenMu.Unlock()
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
})
// API endpoint: check CLI auth enabled status
mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// Success page endpoint
mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, successHTML)
})
server := &http.Server{Handler: mux}
@@ -161,9 +349,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
timeout := time.NewTimer(5 * time.Minute)
defer timeout.Stop()
var authCode string
var result callbackResult
select {
case authCode = <-codeCh:
case result = <-resultCh:
case err := <-errCh:
return nil, err
case <-timeout.C:
@@ -172,11 +360,73 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, ctx.Err()
}
tokenData, err := p.exchangeCode(ctx, authCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
// Handle callback errors
if result.err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
}
// Handle CLI auth disabled - keep server running for user to apply
if result.cliAuthDisabled {
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
// Poll for CLI auth status while waiting
applyTimeout := time.NewTimer(10 * time.Minute)
defer applyTimeout.Stop()
pollTicker := time.NewTicker(5 * time.Second)
defer pollTicker.Stop()
elapsedSeconds := 0
for {
select {
case <-applyTimeout.C:
return nil, errors.New(i18n.T("操作超时,请重新登录"))
case <-ctx.Done():
return nil, ctx.Err()
case <-pollTicker.C:
elapsedSeconds += 5
// Get latest token and state (user may have switched org)
callbackTokenMu.Lock()
currentToken := callbackToken
currentAuthDisabled := callbackAuthDisabled
applySent := callbackApplySent
callbackTokenMu.Unlock()
// Check if user switched to an org with CLI auth enabled
if currentToken != nil && !currentAuthDisabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
}
// Check if CLI auth is now enabled (admin approved)
if currentToken != nil {
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
}
}
// Show polling status based on apply state
if applySent {
_, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds)
} else {
_, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds)
}
}
}
}
continueLogin:
tokenData := result.token
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
+51 -1
View File
@@ -14,7 +14,9 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -103,10 +105,16 @@ func DeleteTokenData(configDir string) error {
return legacyErr
}
// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token.
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout.
// This should be called before deleting local token data.
// The function is best-effort: errors are returned but callers may choose to ignore them.
func RevokeTokenRemote(ctx context.Context) error {
// Use MCP revoke endpoint when clientID is from MCP
if IsClientIDFromMCP() {
return revokeTokenViaMCP(ctx)
}
// Direct mode: use DingTalk logout endpoint
logoutURL, err := url.Parse(LogoutURL)
if err != nil {
return fmt.Errorf("parsing logout URL: %w", err)
@@ -143,3 +151,45 @@ func RevokeTokenRemote(ctx context.Context) error {
return nil
}
// revokeTokenViaMCP revokes token via MCP endpoint.
func revokeTokenViaMCP(ctx context.Context) error {
revokeURL := GetRevokeTokenURL()
if revokeURL == "" {
return nil // No revoke endpoint available
}
// Load current token to get accessToken
tokenData, err := LoadTokenData(getDefaultConfigDir())
if err != nil || tokenData == nil {
return nil // No token to revoke
}
body := map[string]string{
"clientId": ClientID(),
"accessToken": tokenData.AccessToken,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshaling revoke request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("creating revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("calling revoke endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode)
}
return nil
}
+16 -1
View File
@@ -14,8 +14,10 @@
package compat
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
@@ -143,7 +145,20 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
}
}
if blocked, _ := params["_blocked"].(bool); blocked {
return nil
// Interactive confirmation for destructive operations (consistent with Helper commands)
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ This is a destructive operation.")
fmt.Fprint(cmd.ErrOrStderr(), "Confirm? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "yes" && answer != "y" {
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
return nil
}
// User confirmed, continue execution
delete(params, "_blocked")
}
invocation := executor.NewCompatibilityInvocation(
+59
View File
@@ -0,0 +1,59 @@
// 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 errors
import "strings"
// ServerDiagnostics holds server-side diagnostic fields extracted from
// MCP response bodies or HTTP response headers. Fields are populated
// on a best-effort basis during error construction.
type ServerDiagnostics struct {
TraceID string `json:"trace_id,omitempty"`
ServerErrorCode string `json:"server_error_code,omitempty"`
TechnicalDetail string `json:"technical_detail,omitempty"`
ServerRetryable *bool `json:"server_retryable,omitempty"`
}
// IsEmpty returns true when no diagnostic field has been populated.
func (d ServerDiagnostics) IsEmpty() bool {
return d.TraceID == "" && d.ServerErrorCode == "" &&
d.TechnicalDetail == "" && d.ServerRetryable == nil
}
// WithServerDiag attaches server diagnostics to the error.
func WithServerDiag(diag ServerDiagnostics) Option {
if diag.IsEmpty() {
return func(*Error) {}
}
return func(e *Error) {
e.ServerDiag = diag
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
}
}
}
// WithTraceID records the server-provided trace identifier.
// Used when only the trace ID is available (e.g. from HTTP headers)
// without a full ServerDiagnostics struct.
func WithTraceID(id string) Option {
id = strings.TrimSpace(id)
if id == "" {
return func(*Error) {}
}
return func(e *Error) {
e.ServerDiag.TraceID = id
}
}
+100 -36
View File
@@ -14,13 +14,12 @@
package errors
import (
"bytes"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"strings"
"bytes"
)
// Category represents a stable error class with a documented exit code.
@@ -36,18 +35,19 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -247,6 +247,23 @@ func PrintJSON(w io.Writer, err error) error {
errorPayload["rpc_data"] = parsed
}
}
if !typed.ServerDiag.IsEmpty() {
if typed.ServerDiag.TraceID != "" {
errorPayload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode
// Add user-friendly hint for specific server error codes
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings"
}
}
if typed.ServerDiag.TechnicalDetail != "" {
errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail
}
}
if typed.Cause != nil {
errorPayload["cause"] = typed.Cause.Error()
}
@@ -263,8 +280,25 @@ func PrintJSON(w io.Writer, err error) error {
return writeErr
}
// PrintHuman writes a concise human-readable error rendering.
// Verbosity controls how much detail PrintHuman includes.
type Verbosity int
const (
// VerbosityNormal shows essential info: error, hint, actions, trace_id, server_code.
VerbosityNormal Verbosity = 0
// VerbosityVerbose adds technical_detail, snapshot, execution context.
VerbosityVerbose Verbosity = 1
// VerbosityDebug adds all internal diagnostics (category, operation, reason, rpc_code).
VerbosityDebug Verbosity = 2
)
// PrintHuman writes a concise human-readable error rendering at normal verbosity.
func PrintHuman(w io.Writer, err error) error {
return PrintHumanAt(w, err, VerbosityNormal)
}
// PrintHumanAt writes a human-readable error rendering at the given verbosity level.
func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if err == nil {
return nil
}
@@ -275,21 +309,23 @@ func PrintHuman(w io.Writer, err error) error {
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
}
if typed.Reason != "" {
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
}
if typed.Operation != "" {
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
}
if typed.ServerKey != "" {
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
}
// Always shown: hint, actions, retryable
if typed.Hint != "" {
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
}
// Add user-friendly hint for specific server error codes
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings")
}
if len(typed.Actions) > 0 {
for _, action := range typed.Actions {
if strings.TrimSpace(action) == "" {
@@ -298,22 +334,50 @@ func PrintHuman(w io.Writer, err error) error {
lines = append(lines, fmt.Sprintf("Action: %s", action))
}
}
if typed.Snapshot != "" {
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
}
if typed.RPCCode != 0 {
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
}
if len(typed.RPCData) > 0 {
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
}
if typed.Cause != nil {
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
}
if typed.Retryable {
lines = append(lines, "Retryable: true")
}
// Always shown when present: Trace ID, Server Code
if typed.ServerDiag.TraceID != "" {
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
}
if typed.ServerDiag.ServerErrorCode != "" {
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
}
// Verbose+: technical detail, snapshot, reason, server key
if v >= VerbosityVerbose {
if typed.ServerDiag.TechnicalDetail != "" {
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
}
if typed.Reason != "" {
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
}
if typed.ServerKey != "" {
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
}
if typed.Snapshot != "" {
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
}
if typed.Cause != nil {
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
}
}
// Debug: all internal diagnostics
if v >= VerbosityDebug {
if typed.Operation != "" {
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
}
if typed.RPCCode != 0 {
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
}
if len(typed.RPCData) > 0 {
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
}
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
+17 -6
View File
@@ -94,18 +94,29 @@ func TestPrintJSON_AllFields(t *testing.T) {
}
}
func TestPrintHuman_WithCause(t *testing.T) {
func TestPrintHuman_WithCause_Verbose(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityVerbose)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in verbose human output: %s", buf.String())
}
}
func TestPrintHuman_WithCause_NormalHidesCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHuman(&buf, e)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in human output: %s", buf.String())
if strings.Contains(buf.String(), "Cause:") {
t.Fatalf("normal mode should not show Cause: %s", buf.String())
}
}
func TestPrintHuman_AllFields(t *testing.T) {
func TestPrintHuman_AllFields_Debug(t *testing.T) {
t.Parallel()
e := NewAPI("api error",
WithOperation("initialize"),
@@ -120,11 +131,11 @@ func TestPrintHuman_AllFields(t *testing.T) {
WithCause(fmt.Errorf("network")),
)
var buf bytes.Buffer
PrintHuman(&buf, e)
PrintHumanAt(&buf, e, VerbosityDebug)
out := buf.String()
for _, expected := range []string{"API", "initialize", "connection_refused", "doc", "check network", "run again", "snap", "-32601", "network", "Retryable"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in human output: %s", expected, out)
t.Fatalf("missing %q in debug human output: %s", expected, out)
}
}
}
+74 -8
View File
@@ -81,7 +81,7 @@ func TestPrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
if err := PrintHumanAt(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
@@ -90,7 +90,7 @@ func TestPrintHuman(t *testing.T) {
WithRetryable(true),
WithActions("retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
)); err != nil {
), VerbosityVerbose); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
@@ -108,13 +108,64 @@ func TestPrintHuman(t *testing.T) {
t.Fatalf("expected action in output, got %q", got)
}
if !strings.Contains(got, "Snapshot: /tmp/dws-recovery/snapshot.json") {
t.Fatalf("expected snapshot in output, got %q", got)
t.Fatalf("expected snapshot in verbose output, got %q", got)
}
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
}
func TestPrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
PrintHuman(&b, NewValidation(
"bad flag",
WithHint("fix it"),
WithRetryable(true),
WithActions("retry"),
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc", ServerErrorCode: "PARAM_ERROR"}),
))
got := b.String()
if !strings.Contains(got, "Error: [VALIDATION] bad flag") {
t.Fatalf("expected header, got %q", got)
}
if !strings.Contains(got, "Trace ID: trace-abc") {
t.Fatalf("expected trace id in normal output, got %q", got)
}
if !strings.Contains(got, "Server Code: PARAM_ERROR") {
t.Fatalf("expected server code in normal output, got %q", got)
}
}
func TestPrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"server error",
WithServerDiag(ServerDiagnostics{
TraceID: "trace-xyz",
ServerErrorCode: "TIMEOUT_ERROR",
TechnicalDetail: "deadline exceeded",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"trace_id": "trace-xyz"`) {
t.Fatalf("expected trace_id in output, got %q", got)
}
if !strings.Contains(got, `"server_error_code": "TIMEOUT_ERROR"`) {
t.Fatalf("expected server_error_code in output, got %q", got)
}
if !strings.Contains(got, `"technical_detail": "deadline exceeded"`) {
t.Fatalf("expected technical_detail in output, got %q", got)
}
}
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
@@ -137,23 +188,38 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestPrintHumanIncludesRPCCode(t *testing.T) {
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
if err := PrintHumanAt(&b, NewValidation(
"invalid params",
WithRPCCode(-32602),
WithRPCData([]byte(`"missing field"`)),
)); err != nil {
), VerbosityDebug); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
got := b.String()
if !strings.Contains(got, "RPC Code: -32602") {
t.Fatalf("expected RPC Code in output, got %q", got)
t.Fatalf("expected RPC Code in debug output, got %q", got)
}
if !strings.Contains(got, "RPC Data:") {
t.Fatalf("expected RPC Data in output, got %q", got)
t.Fatalf("expected RPC Data in debug output, got %q", got)
}
}
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
PrintHuman(&b, NewValidation(
"invalid params",
WithRPCCode(-32602),
))
got := b.String()
if strings.Contains(got, "RPC Code:") {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
+515
View File
@@ -0,0 +1,515 @@
// 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 helpers
import (
"encoding/json"
"fmt"
"strings"
"time"
"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/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return reportHandler{}
})
}
type reportHandler struct{}
func (reportHandler) Name() string {
return "report"
}
func (reportHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "report",
Aliases: []string{"log"},
Short: "日志 / 模版 / 统计",
Long: `钉钉日志:模版、创建、详情、列表、统计。
子命令:
template 日志模版(list / detail)
create 创建日志
detail 获取日志详情
list 查询收到的日志列表
stats 获取日志统计数据
sent 查询已发送的日志列表`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
template := &cobra.Command{
Use: "template",
Short: "日志模版",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
template.AddCommand(
newReportTemplateListCommand(runner),
newReportTemplateDetailCommand(runner),
)
root.AddCommand(
template,
newReportCreateCommand(runner),
newReportDetailCommand(runner),
newReportListCommand(runner),
newReportStatsCommand(runner),
newReportSentCommand(runner),
)
return root
}
// ── flexTimeLayouts: supported date formats, most specific first ──
var flexTimeLayouts = []string{
time.RFC3339, // 2006-01-02T15:04:05+08:00
"2006-01-02T15:04:05Z", // UTC Z suffix
"2006-01-02T15:04:05-07:00", // with offset but no colon
"2006-01-02T15:04:05", // no timezone
"2006-01-02 15:04:05", // space-separated
"2006-01-02T15:04", // no seconds
"2006-01-02 15:04", // no seconds, space
"2006-01-02", // date only
"2006/01/02 15:04:05", // slash + time
"2006/01/02", // slash date
"20060102", // compact YYYYMMDD
}
// parseFlexTimeToMillis parses a date string using multiple formats and returns Unix milliseconds.
// Supports 11 formats for maximum compatibility with user input.
func parseFlexTimeToMillis(flagName, value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, apperrors.NewValidation(fmt.Sprintf(
"--%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName))
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.Local
}
for _, layout := range flexTimeLayouts {
t, err := time.ParseInLocation(layout, value, loc)
if err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation(fmt.Sprintf(
"cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23",
flagName, value))
}
// validateTimeRange checks that endMs is strictly after startMs.
func validateTimeRange(startMs, endMs int64) error {
if endMs <= startMs {
return apperrors.NewValidation("--end must be after --start\n hint: swap the values or adjust the time range")
}
return nil
}
// ── template list ──────────────────────────────────────────
func newReportTemplateListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "获取当前用户可用的日志模版列表",
Example: " dws report template list",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_available_report_templates", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_available_report_templates", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
return cmd
}
// ── template detail ────────────────────────────────────────
func newReportTemplateDetailCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "detail",
Short: "获取日志模版详情",
Example: " dws report template detail --name <templateName>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
if name == "" {
return apperrors.NewValidation("--name is required")
}
params := map[string]any{
"report_template_name": name,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_template_details_by_name", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_template_details_by_name", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("name", "", "模版名称 (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── create ─────────────────────────────────────────────────
func newReportCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "创建日志",
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
tplID, _ := cmd.Flags().GetString("template-id")
if tplID == "" {
return apperrors.NewValidation("--template-id is required")
}
contentsJSON, _ := cmd.Flags().GetString("contents")
if contentsJSON == "" {
return apperrors.NewValidation("--contents is required")
}
var contents []map[string]any
if err := json.Unmarshal([]byte(contentsJSON), &contents); err != nil {
return apperrors.NewValidation(fmt.Sprintf("--contents JSON parse failed: %v", err))
}
ddFrom, _ := cmd.Flags().GetString("dd-from")
if ddFrom == "" {
ddFrom = "dws"
}
toChat, _ := cmd.Flags().GetBool("to-chat")
params := map[string]any{
"templateId": tplID,
"contents": contents,
"ddFrom": ddFrom,
"toChat": toChat,
}
if v, _ := cmd.Flags().GetString("to-user-ids"); v != "" {
params["toUserIds"] = parseUserIDs(v)
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "create_report", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "create_report", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
cmd.Flags().String("dd-from", "dws", "创建来源标识")
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
preferLegacyLeaf(cmd)
return cmd
}
// ── detail ─────────────────────────────────────────────────
func newReportDetailCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "detail",
Short: "获取日志详情",
Example: " dws report detail --report-id <reportId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
reportID, _ := cmd.Flags().GetString("report-id")
if reportID == "" {
return apperrors.NewValidation("--report-id is required")
}
params := map[string]any{
"report_id": reportID,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_entry_details", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_entry_details", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("report-id", "", "日志 ID (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── list (received reports) ────────────────────────────────
// Key fix: cursor defaults to 0, size defaults to 20, flexible date parsing
func newReportListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询当前人收到的日志列表",
Example: ` dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws report list --start "2026-03-10 00:00:00" --end "2026-03-10 23:59:59" --cursor 0 --size 20`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
startMs, err := parseFlexTimeToMillis("start", startStr)
if err != nil {
return err
}
endMs, err := parseFlexTimeToMillis("end", endStr)
if err != nil {
return err
}
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
// cursor defaults to 0, size defaults to 20
cursor, _ := cmd.Flags().GetInt("cursor")
size, _ := cmd.Flags().GetInt("size")
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
size = v
}
params := map[string]any{
"startTime": float64(startMs),
"endTime": float64(endMs),
"cursor": float64(cursor),
"size": float64(size),
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_received_report_list", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_received_report_list", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
cmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
cmd.Flags().Int("cursor", 0, "分页游标,首次传 0 (默认 0)")
cmd.Flags().Int("size", 20, "每页条数,最大 20 (默认 20)")
cmd.Flags().Int("limit", 0, "--size 的别名")
_ = cmd.Flags().MarkHidden("limit")
preferLegacyLeaf(cmd)
return cmd
}
// ── stats ──────────────────────────────────────────────────
func newReportStatsCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "获取日志统计数据",
Example: " dws report stats --report-id <reportId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
reportID, _ := cmd.Flags().GetString("report-id")
if reportID == "" {
return apperrors.NewValidation("--report-id is required")
}
params := map[string]any{
"report_id": reportID,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_statistics_by_id", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_statistics_by_id", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("report-id", "", "日志 ID (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── sent (my created reports) ──────────────────────────────
// Key fix: cursor defaults to 0, size defaults to 20, start/end default to last 30 days
func newReportSentCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "sent",
Short: "查询当前人创建的日志列表",
Example: ` dws report sent
dws report sent --cursor 0 --size 20
dws report sent --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws report sent --template-name "日报"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
// cursor defaults to 0, size defaults to 20
cursor, _ := cmd.Flags().GetInt("cursor")
size, _ := cmd.Flags().GetInt("size")
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
size = v
}
params := map[string]any{
"cursor": float64(cursor),
"size": float64(size),
}
// Default time range: last 30 days
now := time.Now()
startDefault := now.AddDate(0, 0, -30).Truncate(24 * time.Hour).Format(time.RFC3339)
endDefault := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format(time.RFC3339)
startStr, _ := cmd.Flags().GetString("start")
if startStr == "" {
startStr = startDefault
}
endStr, _ := cmd.Flags().GetString("end")
if endStr == "" {
endStr = endDefault
}
startMs, err := parseFlexTimeToMillis("start", startStr)
if err != nil {
return err
}
params["startTime"] = float64(startMs)
endMs, err := parseFlexTimeToMillis("end", endStr)
if err != nil {
return err
}
params["endTime"] = float64(endMs)
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
// Optional modified time filters
if v, _ := cmd.Flags().GetString("modified-start"); v != "" {
ms, err := parseFlexTimeToMillis("modified-start", v)
if err != nil {
return err
}
params["modifiedStartTime"] = float64(ms)
}
if v, _ := cmd.Flags().GetString("modified-end"); v != "" {
ms, err := parseFlexTimeToMillis("modified-end", v)
if err != nil {
return err
}
params["modifiedEndTime"] = float64(ms)
}
// Optional template name filter
if v, _ := cmd.Flags().GetString("template-name"); v != "" {
params["report_template_name"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_send_report_list", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_send_report_list", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().Int("cursor", 0, "分页游标,首次传 0 (默认 0)")
cmd.Flags().Int("size", 20, "每页条数,最大 20 (默认 20)")
cmd.Flags().Int("limit", 0, "--size 的别名")
_ = cmd.Flags().MarkHidden("limit")
cmd.Flags().String("start", "", "创建开始时间 ISO-8601 (默认最近 30 天)")
cmd.Flags().String("end", "", "创建结束时间 ISO-8601 (默认最近 30 天)")
cmd.Flags().String("modified-start", "", "修改开始时间 ISO-8601 (可选)")
cmd.Flags().String("modified-end", "", "修改结束时间 ISO-8601 (可选)")
cmd.Flags().String("template-name", "", "日志模板名称 (可选,不传查全部)")
preferLegacyLeaf(cmd)
return cmd
}
// ── helpers ────────────────────────────────────────────────
func parseUserIDs(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+149
View File
@@ -0,0 +1,149 @@
// 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 helpers
import (
"testing"
"time"
)
func TestParseFlexTimeToMillis_RFC3339(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-10T00:00:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_SpaceSeparated(t *testing.T) {
t.Parallel()
// This is the format that was causing the HTTP 400 error
ms, err := parseFlexTimeToMillis("start", "2026-03-01 00:00:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_DateOnly(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-01")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_NoTimezone(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-10T14:00:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_SlashFormat(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026/03/10")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_CompactFormat(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "20260310")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_Empty(t *testing.T) {
t.Parallel()
_, err := parseFlexTimeToMillis("start", "")
if err == nil {
t.Fatal("expected error for empty value")
}
}
func TestParseFlexTimeToMillis_Invalid(t *testing.T) {
t.Parallel()
_, err := parseFlexTimeToMillis("start", "not-a-date")
if err == nil {
t.Fatal("expected error for invalid date")
}
}
func TestValidateTimeRange_Valid(t *testing.T) {
t.Parallel()
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
end := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(start, end); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateTimeRange_Invalid(t *testing.T) {
t.Parallel()
start := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC).UnixMilli()
end := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(start, end); err == nil {
t.Fatal("expected error when end is before start")
}
}
func TestValidateTimeRange_Equal(t *testing.T) {
t.Parallel()
ts := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(ts, ts); err == nil {
t.Fatal("expected error when start equals end")
}
}
func TestParseUserIDs(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want int
}{
{"user1,user2,user3", 3},
{"user1", 1},
{"user1, user2, user3", 3},
{"user1,,user2", 2},
{"", 0},
{" , , ", 0},
}
for _, tt := range tests {
got := parseUserIDs(tt.input)
if len(got) != tt.want {
t.Errorf("parseUserIDs(%q) = %d items, want %d", tt.input, len(got), tt.want)
}
}
}
+89 -2
View File
@@ -13,7 +13,14 @@
package logging
import "strings"
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
)
// sensitiveKeys are header/field names whose values must be redacted in logs.
var sensitiveKeys = map[string]bool{
@@ -25,12 +32,30 @@ var sensitiveKeys = map[string]bool{
"secret": true,
"password": true,
"cookie": true,
"api_key": true,
"api-key": true,
"access_token": true,
"credential": true,
}
// sensitiveSubstrings are substrings that mark a key as sensitive.
var sensitiveSubstrings = []string{
"password", "secret", "token", "credential",
}
// IsSensitiveKey returns true if the key (case-insensitive) refers to a
// credential or secret that must not appear in log files.
func IsSensitiveKey(key string) bool {
return sensitiveKeys[strings.ToLower(key)]
lower := strings.ToLower(key)
if sensitiveKeys[lower] {
return true
}
for _, sub := range sensitiveSubstrings {
if strings.Contains(lower, sub) {
return true
}
}
return false
}
// RedactValue replaces a sensitive value with a safe placeholder.
@@ -42,3 +67,65 @@ func RedactValue(value string) string {
}
return value[:4] + "***"
}
// TruncateBody returns the body truncated to maxBytes with a UTF-8 safe
// boundary. If truncated, appends a marker showing the original size.
func TruncateBody(body []byte, maxBytes int) string {
if len(body) <= maxBytes {
return string(body)
}
safe := body[:maxBytes]
// Walk back to a valid UTF-8 boundary.
for len(safe) > 0 && !utf8.Valid(safe) {
safe = safe[:len(safe)-1]
}
return fmt.Sprintf("%s...(truncated, total=%d bytes)", string(safe), len(body))
}
// SanitizeArguments returns a JSON string of the arguments map with
// sensitive-looking values replaced by "***". Truncates to maxBytes.
func SanitizeArguments(args map[string]any, maxBytes int) string {
if len(args) == 0 {
return "{}"
}
sanitized := make(map[string]any, len(args))
for k, v := range args {
sanitized[k] = v
}
redactMapValues(sanitized)
data, err := json.Marshal(sanitized)
if err != nil {
return "{}"
}
return TruncateBody(data, maxBytes)
}
// redactMapValues replaces values of sensitive keys with "***" in-place.
func redactMapValues(m map[string]any) {
for k, v := range m {
if IsSensitiveKey(k) {
m[k] = "***"
continue
}
if nested, ok := v.(map[string]any); ok {
redactMapValues(nested)
}
}
}
// RedactHeaders returns slog attributes for HTTP headers with sensitive
// values redacted.
func RedactHeaders(headers http.Header) []slog.Attr {
if len(headers) == 0 {
return nil
}
attrs := make([]slog.Attr, 0, len(headers))
for key := range headers {
value := headers.Get(key)
if IsSensitiveKey(key) {
value = RedactValue(value)
}
attrs = append(attrs, slog.String("header."+strings.ToLower(key), value))
}
return attrs
}
+99 -1
View File
@@ -13,7 +13,11 @@
package logging
import "testing"
import (
"net/http"
"strings"
"testing"
)
func TestIsSensitiveKey(t *testing.T) {
t.Parallel()
@@ -69,3 +73,97 @@ func TestRedactValue(t *testing.T) {
})
}
}
func TestTruncateBody(t *testing.T) {
t.Parallel()
short := []byte("hello")
if got := TruncateBody(short, 100); got != "hello" {
t.Fatalf("expected no truncation, got %q", got)
}
long := []byte(strings.Repeat("a", 200))
got := TruncateBody(long, 50)
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
if !strings.Contains(got, "total=200") {
t.Fatalf("expected total size, got %q", got)
}
}
func TestTruncateBody_Empty(t *testing.T) {
t.Parallel()
if got := TruncateBody(nil, 100); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestSanitizeArguments(t *testing.T) {
t.Parallel()
args := map[string]any{
"name": "test",
"password": "secret123",
"nested": map[string]any{
"api_key": "key-value",
"safe": "ok",
},
}
got := SanitizeArguments(args, 4096)
if strings.Contains(got, "secret123") {
t.Fatalf("password should be redacted: %s", got)
}
if strings.Contains(got, "key-value") {
t.Fatalf("api_key should be redacted: %s", got)
}
if !strings.Contains(got, "test") {
t.Fatalf("non-sensitive value should remain: %s", got)
}
}
func TestSanitizeArguments_Empty(t *testing.T) {
t.Parallel()
if got := SanitizeArguments(nil, 100); got != "{}" {
t.Fatalf("expected {}, got %q", got)
}
}
func TestRedactHeaders(t *testing.T) {
t.Parallel()
headers := http.Header{
"Authorization": {"Bearer token123456"},
"Content-Type": {"application/json"},
}
attrs := RedactHeaders(headers)
if len(attrs) != 2 {
t.Fatalf("expected 2 attrs, got %d", len(attrs))
}
for _, attr := range attrs {
if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") {
t.Fatalf("authorization should be redacted: %s", attr.Value.String())
}
if attr.Key == "header.content-type" && attr.Value.String() != "application/json" {
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
}
}
}
func TestIsSensitiveKey_Substrings(t *testing.T) {
t.Parallel()
tests := []struct {
key string
want bool
}{
{"x-api-token", true},
{"user_password_hash", true},
{"my_secret_key", true},
{"x-credential-id", true},
{"safe-header", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
t.Parallel()
if got := IsSensitiveKey(tt.key); got != tt.want {
t.Errorf("IsSensitiveKey(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
+118
View File
@@ -19,6 +19,13 @@ import (
"time"
)
const (
// maxBodyLogSize is the maximum bytes of request/response body to log.
maxBodyLogSize = 4096
// maxArgLogSize is the maximum bytes for sanitized argument summaries.
maxArgLogSize = 1024
)
// LogRequest logs a JSON-RPC request at Debug level.
func LogRequest(logger *slog.Logger, method, endpoint, executionId string, bodySize int) {
if logger == nil {
@@ -32,6 +39,19 @@ func LogRequest(logger *slog.Logger, method, endpoint, executionId string, bodyS
)
}
// LogRequestBody logs a truncated, redacted request body for tools/call.
func LogRequestBody(logger *slog.Logger, method, executionId string, toolName string, arguments map[string]any) {
if logger == nil || method != "tools/call" {
return
}
logger.Debug("jsonrpc_request_body",
slog.String("method", method),
slog.String("execution_id", executionId),
slog.String("tool_name", toolName),
slog.String("arguments_summary", SanitizeArguments(arguments, maxArgLogSize)),
)
}
// LogResponse logs a JSON-RPC response at Debug level.
func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, respSize int, duration time.Duration, err error) {
if logger == nil {
@@ -52,6 +72,104 @@ func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, r
logger.LogAttrs(context.TODO(), slog.LevelDebug, "jsonrpc_response", attrs...)
}
// LogResponseBody logs a truncated response body on error paths.
func LogResponseBody(logger *slog.Logger, method, executionId string, statusCode int, body []byte, traceID string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.Int("status", statusCode),
slog.String("body", TruncateBody(body, maxBodyLogSize)),
}
if traceID != "" {
attrs = append(attrs, slog.String("trace_id", traceID))
}
level := slog.LevelDebug
if statusCode >= 400 {
level = slog.LevelWarn
}
logger.LogAttrs(context.TODO(), level, "jsonrpc_response_body", attrs...)
}
// LogRetryAttempt logs a retry attempt at Warn level.
func LogRetryAttempt(logger *slog.Logger, method, executionId string, attempt, maxRetries int, statusCode int, delay time.Duration, lastErr error) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.Int("attempt", attempt+1),
slog.Int("max_attempts", maxRetries+1),
slog.Int("status", statusCode),
slog.String("delay", delay.String()),
}
if lastErr != nil {
attrs = append(attrs, slog.String("error", lastErr.Error()))
}
logger.LogAttrs(context.TODO(), slog.LevelWarn, "jsonrpc_retry", attrs...)
}
// LogErrorClassified logs the final error classification at Warn level.
func LogErrorClassified(logger *slog.Logger, method, executionId, category, reason string, httpStatus, rpcCode int, retryable bool, traceID string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.String("category", category),
slog.String("reason", reason),
slog.Bool("retryable", retryable),
}
if httpStatus != 0 {
attrs = append(attrs, slog.Int("http_status", httpStatus))
}
if rpcCode != 0 {
attrs = append(attrs, slog.Int("rpc_code", rpcCode))
}
if traceID != "" {
attrs = append(attrs, slog.String("trace_id", traceID))
}
logger.LogAttrs(context.TODO(), slog.LevelWarn, "error_classified", attrs...)
}
// LogCommandStart logs the beginning of a command execution.
func LogCommandStart(logger *slog.Logger, executionId, command, product, tool, version string, authPresent bool) {
if logger == nil {
return
}
logger.Info("command_start",
slog.String("execution_id", executionId),
slog.String("command", command),
slog.String("product", product),
slog.String("tool", tool),
slog.String("cli_version", version),
slog.Bool("auth_token_present", authPresent),
)
}
// LogCommandEnd logs the end of a command execution.
func LogCommandEnd(logger *slog.Logger, executionId, product, tool string, success bool, duration time.Duration, errCategory, errReason string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("execution_id", executionId),
slog.String("product", product),
slog.String("tool", tool),
slog.Bool("success", success),
slog.String("duration", duration.Truncate(time.Millisecond).String()),
}
if !success {
attrs = append(attrs, slog.String("error_category", errCategory))
attrs = append(attrs, slog.String("error_reason", errReason))
}
logger.LogAttrs(context.TODO(), slog.LevelInfo, "command_end", attrs...)
}
// redactEndpoint removes query parameters from endpoint URLs in logs.
func redactEndpoint(endpoint string) string {
for i := 0; i < len(endpoint); i++ {
+88
View File
@@ -88,6 +88,94 @@ func TestLogRequestNilLogger(t *testing.T) {
// Should not panic
LogRequest(nil, "test", "http://localhost", "", 0)
LogResponse(nil, "test", "http://localhost", 200, 0, 0, nil)
LogRequestBody(nil, "tools/call", "exec-1", "tool", nil)
LogResponseBody(nil, "tools/call", "exec-1", 200, nil, "")
LogRetryAttempt(nil, "tools/call", "exec-1", 0, 2, 429, 0, nil)
LogErrorClassified(nil, "tools/call", "exec-1", "api", "timeout", 0, 0, true, "")
LogCommandStart(nil, "exec-1", "dws test", "doc", "list", "1.0.0", false)
LogCommandEnd(nil, "exec-1", "doc", "list", true, 0, "", "")
}
func TestLogRequestBody(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
args := map[string]any{"name": "test", "limit": 10}
LogRequestBody(logger, "tools/call", "exec-1", "doc.list", args)
out := buf.String()
if !strings.Contains(out, "jsonrpc_request_body") {
t.Error("missing message")
}
if !strings.Contains(out, "doc.list") {
t.Error("missing tool_name")
}
if !strings.Contains(out, "exec-1") {
t.Error("missing execution_id")
}
}
func TestLogRequestBody_SkipsNonToolsCall(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogRequestBody(logger, "initialize", "exec-1", "", nil)
if buf.Len() != 0 {
t.Error("should not log body for non-tools/call methods")
}
}
func TestLogResponseBody(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogResponseBody(logger, "tools/call", "exec-1", 500, []byte(`{"error":"fail"}`), "trace-abc")
out := buf.String()
if !strings.Contains(out, "jsonrpc_response_body") {
t.Error("missing message")
}
if !strings.Contains(out, "trace-abc") {
t.Error("missing trace_id")
}
if !strings.Contains(out, "WARN") {
t.Error("expected WARN level for 500 status")
}
}
func TestLogRetryAttempt(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogRetryAttempt(logger, "tools/call", "exec-1", 0, 2, 429, 10*time.Millisecond, errors.New("rate limited"))
out := buf.String()
if !strings.Contains(out, "jsonrpc_retry") {
t.Error("missing message")
}
if !strings.Contains(out, "rate limited") {
t.Error("missing error")
}
}
func TestLogErrorClassified(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogErrorClassified(logger, "tools/call", "exec-1", "auth", "http_401", 401, 0, false, "trace-xyz")
out := buf.String()
if !strings.Contains(out, "error_classified") {
t.Error("missing message")
}
if !strings.Contains(out, "trace-xyz") {
t.Error("missing trace_id")
}
}
func TestRedactEndpoint(t *testing.T) {
+37 -5
View File
@@ -344,6 +344,12 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
logging.LogRequest(c.FileLogger, request.Method, endpoint, c.ExecutionId, len(body))
// Log request body details for tools/call (arguments are sanitized).
if params, ok := request.Params.(map[string]any); ok {
toolName, _ := params["name"].(string)
args, _ := params["arguments"].(map[string]any)
logging.LogRequestBody(c.FileLogger, request.Method, c.ExecutionId, toolName, args)
}
callStart := time.Now()
resp, err := c.doWithRetry(ctx, endpoint, body)
@@ -352,6 +358,9 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
defer resp.Body.Close()
// Extract trace ID from response headers for correlation.
headerTraceID := ExtractTraceIDFromHeaders(resp.Header)
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
logging.LogResponse(c.FileLogger, request.Method, endpoint, resp.StatusCode, len(data), time.Since(callStart), err)
if err != nil {
@@ -361,6 +370,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
apperrors.WithReason(reasonForMethod(request.Method, "response_read_failed")),
apperrors.WithHint(i18n.T("检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。")),
apperrors.WithActions(discoveryActions("")...),
apperrors.WithTraceID(headerTraceID),
)
}
snapshotPath := ""
@@ -369,7 +379,8 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath)
logging.LogResponseBody(c.FileLogger, request.Method, c.ExecutionId, resp.StatusCode, data, headerTraceID)
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath, headerTraceID)
}
if !expectResponse {
@@ -385,10 +396,12 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
apperrors.WithHint(i18n.T("MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithTraceID(headerTraceID),
)
}
if envelope.Error != nil {
return jsonrpcEnvelopeError(request.Method, envelope.Error, snapshotPath)
logging.LogResponseBody(c.FileLogger, request.Method, c.ExecutionId, resp.StatusCode, data, headerTraceID)
return jsonrpcEnvelopeError(request.Method, envelope.Error, snapshotPath, headerTraceID)
}
if len(envelope.Result) == 0 {
return apperrors.NewDiscovery(
@@ -464,7 +477,14 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
}
if attempt < c.MaxRetries {
delay := c.retryDelayForAttempt(attempt, respRetryAfter(resp))
retryAfter := ""
statusForLog := 0
if resp != nil {
retryAfter = respRetryAfter(resp)
statusForLog = resp.StatusCode
}
delay := c.retryDelayForAttempt(attempt, retryAfter)
logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
if err := c.sleepForRetry(ctx, delay); err != nil {
return nil, apperrors.NewDiscovery(
"request cancelled during retry",
@@ -657,16 +677,18 @@ func sanitizeBearerToken(raw string) string {
return token
}
func httpStatusError(method, endpoint string, statusCode int, snapshotPath string) error {
func httpStatusError(method, endpoint string, statusCode int, snapshotPath, headerTraceID string) error {
message := fmt.Sprintf("request to %s returned HTTP %d", RedactURL(endpoint), statusCode)
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(fmt.Sprintf("http_%d", statusCode)),
apperrors.WithRetryable(retryable(statusCode)),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithTraceID(headerTraceID),
apperrors.WithCause(&CallError{
Stage: CallStageHTTP,
HTTPStatus: statusCode,
TraceID: headerTraceID,
Cause: errors.New(message),
}),
}
@@ -706,18 +728,28 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath strin
}
}
func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath string) error {
func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath, headerTraceID string) error {
message := fmt.Sprintf("JSON-RPC %s failed with code %d: %s", method, rpcErr.Code, rpcErr.Message)
reason := reasonForMethod(method, "jsonrpc_"+jsonrpcCodeLabel(rpcErr.Code))
// Extract structured diagnostics from rpc error data.
diag := ExtractServerDiagnostics(rpcErr.Data)
// Prefer trace ID from structured data; fall back to HTTP header.
if diag.TraceID == "" && headerTraceID != "" {
diag.TraceID = headerTraceID
}
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(reason),
apperrors.WithRPCCode(rpcErr.Code),
apperrors.WithRPCData(rpcErr.Data),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithServerDiag(diag),
apperrors.WithCause(&CallError{
Stage: CallStageJSONRPC,
RPCCode: rpcErr.Code,
TraceID: diag.TraceID,
Cause: errors.New(message),
}),
}
+1 -1
View File
@@ -207,7 +207,7 @@ func TestHttpStatusError(t *testing.T) {
http.StatusInternalServerError,
}
for _, code := range codes {
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "")
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
+7 -7
View File
@@ -199,7 +199,7 @@ func TestLooksAuthRPCError(t *testing.T) {
func TestJsonrpcEnvelopeError_InvalidParams(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -207,7 +207,7 @@ func TestJsonrpcEnvelopeError_InvalidParams(t *testing.T) {
func TestJsonrpcEnvelopeError_AuthError(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: 401, Message: "Unauthorized"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: 401, Message: "Unauthorized"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -215,7 +215,7 @@ func TestJsonrpcEnvelopeError_AuthError(t *testing.T) {
func TestJsonrpcEnvelopeError_MethodNotFound(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32601, Message: "method not found"}, "/tmp/snap")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32601, Message: "method not found"}, "/tmp/snap", "")
if err == nil {
t.Fatal("expected error")
}
@@ -223,7 +223,7 @@ func TestJsonrpcEnvelopeError_MethodNotFound(t *testing.T) {
func TestJsonrpcEnvelopeError_GenericToolError(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32000, Message: "server error"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32000, Message: "server error"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -231,7 +231,7 @@ func TestJsonrpcEnvelopeError_GenericToolError(t *testing.T) {
func TestJsonrpcEnvelopeError_DiscoveryMethod(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("initialize", &RPCError{Code: -32000, Message: "failed"}, "")
err := jsonrpcEnvelopeError("initialize", &RPCError{Code: -32000, Message: "failed"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -294,7 +294,7 @@ func TestCallTool_RetriesOn502(t *testing.T) {
func TestHttpStatusError_AllCodes(t *testing.T) {
t.Parallel()
for _, code := range []int{400, 401, 403, 404, 429, 500, 502, 503} {
err := httpStatusError("tools/call", "https://api.example.com", code, "")
err := httpStatusError("tools/call", "https://api.example.com", code, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
@@ -303,7 +303,7 @@ func TestHttpStatusError_AllCodes(t *testing.T) {
func TestHttpStatusError_WithSnapshot(t *testing.T) {
t.Parallel()
err := httpStatusError("initialize", "https://api.example.com", 500, "/tmp/snap.json")
err := httpStatusError("initialize", "https://api.example.com", 500, "/tmp/snap.json", "")
if err == nil {
t.Fatal("expected error")
}
+104
View File
@@ -0,0 +1,104 @@
// 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 transport
import (
"encoding/json"
"net/http"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// serverDiagFields maps the various JSON field names the server may use
// for diagnostic information. Both snake_case and camelCase are supported.
type serverDiagFields struct {
TraceID string `json:"trace_id"`
TraceIDCamel string `json:"traceId"`
Code string `json:"code"`
ErrorCode string `json:"errorCode"`
TechnicalDetail string `json:"technical_detail"`
Retryable *bool `json:"retryable"`
}
// ExtractServerDiagnostics parses server diagnostic fields from a JSON
// payload (typically from RPCError.Data). Returns an empty struct if
// the payload is empty or unparseable.
func ExtractServerDiagnostics(data json.RawMessage) apperrors.ServerDiagnostics {
if len(data) == 0 {
return apperrors.ServerDiagnostics{}
}
var fields serverDiagFields
if json.Unmarshal(data, &fields) != nil {
return apperrors.ServerDiagnostics{}
}
return apperrors.ServerDiagnostics{
TraceID: coalesceStr(fields.TraceID, fields.TraceIDCamel),
ServerErrorCode: coalesceStr(fields.Code, fields.ErrorCode),
TechnicalDetail: fields.TechnicalDetail,
ServerRetryable: fields.Retryable,
}
}
// ExtractServerDiagnosticsFromMap parses server diagnostic fields from a
// map[string]any (typically from ToolCallResult.Content for business errors).
func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDiagnostics {
if len(content) == 0 {
return apperrors.ServerDiagnostics{}
}
diag := apperrors.ServerDiagnostics{
TraceID: stringFromMap(content, "trace_id", "traceId"),
ServerErrorCode: stringFromMap(content, "code", "errorCode"),
TechnicalDetail: stringFromMap(content, "technical_detail"),
}
if v, ok := content["retryable"].(bool); ok {
diag.ServerRetryable = &v
}
return diag
}
// ExtractTraceIDFromHeaders reads a trace ID from standard HTTP response
// headers. Returns empty string if none found.
func ExtractTraceIDFromHeaders(headers http.Header) string {
for _, key := range []string{
"X-Trace-Id",
"X-Request-Id",
"x-dingtalk-trace-id",
} {
if v := headers.Get(key); v != "" {
return v
}
}
return ""
}
// coalesceStr returns the first non-empty string.
func coalesceStr(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// stringFromMap returns the first non-empty string value found for any of
// the given keys in the map.
func stringFromMap(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok && v != "" {
return v
}
}
return ""
}
+124
View File
@@ -0,0 +1,124 @@
package transport
import (
"encoding/json"
"net/http"
"testing"
)
func TestExtractServerDiagnostics_SnakeCase(t *testing.T) {
t.Parallel()
data := json.RawMessage(`{"trace_id":"abc123","code":"PARAM_ERROR","technical_detail":"field required","retryable":false}`)
diag := ExtractServerDiagnostics(data)
if diag.TraceID != "abc123" {
t.Fatalf("TraceID = %q, want abc123", diag.TraceID)
}
if diag.ServerErrorCode != "PARAM_ERROR" {
t.Fatalf("ServerErrorCode = %q, want PARAM_ERROR", diag.ServerErrorCode)
}
if diag.TechnicalDetail != "field required" {
t.Fatalf("TechnicalDetail = %q, want 'field required'", diag.TechnicalDetail)
}
if diag.ServerRetryable == nil || *diag.ServerRetryable != false {
t.Fatalf("ServerRetryable = %v, want false", diag.ServerRetryable)
}
}
func TestExtractServerDiagnostics_CamelCase(t *testing.T) {
t.Parallel()
data := json.RawMessage(`{"traceId":"xyz789","errorCode":"AUTH_ERROR"}`)
diag := ExtractServerDiagnostics(data)
if diag.TraceID != "xyz789" {
t.Fatalf("TraceID = %q, want xyz789", diag.TraceID)
}
if diag.ServerErrorCode != "AUTH_ERROR" {
t.Fatalf("ServerErrorCode = %q, want AUTH_ERROR", diag.ServerErrorCode)
}
}
func TestExtractServerDiagnostics_Empty(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnostics(nil)
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for nil input")
}
diag = ExtractServerDiagnostics(json.RawMessage(`{}`))
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for empty object")
}
}
func TestExtractServerDiagnostics_Malformed(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnostics(json.RawMessage(`not json`))
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for malformed JSON")
}
}
func TestExtractServerDiagnosticsFromMap(t *testing.T) {
t.Parallel()
content := map[string]any{
"trace_id": "trace-001",
"code": "TIMEOUT_ERROR",
"technical_detail": "deadline exceeded",
"retryable": true,
}
diag := ExtractServerDiagnosticsFromMap(content)
if diag.TraceID != "trace-001" {
t.Fatalf("TraceID = %q, want trace-001", diag.TraceID)
}
if diag.ServerErrorCode != "TIMEOUT_ERROR" {
t.Fatalf("ServerErrorCode = %q, want TIMEOUT_ERROR", diag.ServerErrorCode)
}
if diag.ServerRetryable == nil || *diag.ServerRetryable != true {
t.Fatal("expected retryable=true")
}
}
func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnosticsFromMap(nil)
if !diag.IsEmpty() {
t.Fatal("expected empty for nil map")
}
}
func TestExtractTraceIDFromHeaders(t *testing.T) {
t.Parallel()
tests := []struct {
name string
headers http.Header
want string
}{
{"x-trace-id", http.Header{"X-Trace-Id": {"abc"}}, "abc"},
{"x-request-id", http.Header{"X-Request-Id": {"def"}}, "def"},
{"dingtalk", http.Header{"X-Dingtalk-Trace-Id": {"ghi"}}, "ghi"},
{"priority", http.Header{
"X-Trace-Id": {"first"},
"X-Dingtalk-Trace-Id": {"second"},
}, "first"},
{"empty", http.Header{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ExtractTraceIDFromHeaders(tt.headers); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCoalesceStr(t *testing.T) {
t.Parallel()
if got := coalesceStr("", "b"); got != "b" {
t.Fatalf("got %q, want b", got)
}
if got := coalesceStr("a", "b"); got != "a" {
t.Fatalf("got %q, want a", got)
}
if got := coalesceStr("", ""); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
)
func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "")
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "", "")
var callErr *CallError
if !errors.As(err, &callErr) {
@@ -33,7 +33,7 @@ func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
}
func TestJSONRPCEnvelopeErrorIncludesCallMetadata(t *testing.T) {
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "", "")
var callErr *CallError
if !errors.As(err, &callErr) {
+52 -5
View File
@@ -14,6 +14,7 @@
# Environment variables (all optional):
# DWS_INSTALL_DIR — where to put the binary (default: ~/.local/bin)
# DWS_VERSION — version to install (default: latest)
# DWS_ARCH — architecture override (amd64 or arm64)
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
@@ -44,12 +45,58 @@ function Write-Err {
}
function Get-Arch {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($arch) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
default { Write-Err "Unsupported architecture: $arch" }
# Allow manual override via environment variable
if ($env:DWS_ARCH) {
$override = $env:DWS_ARCH.ToLower()
if ($override -eq "amd64" -or $override -eq "arm64") {
return $override
}
Write-Err "Invalid DWS_ARCH value '$env:DWS_ARCH'. Must be 'amd64' or 'arm64'."
}
# Method 1: Try RuntimeInformation (available in .NET Core / PowerShell 6+)
try {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
if ($arch) {
switch ($arch.ToString()) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
}
}
} catch {}
# Method 2: Check PROCESSOR_ARCHITECTURE environment variable (Windows)
$envArch = $env:PROCESSOR_ARCHITECTURE
if ($envArch) {
switch ($envArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
"X86" {
# 32-bit process on 64-bit OS?
$realArch = $env:PROCESSOR_ARCHITEW6432
if ($realArch) {
switch ($realArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
}
}
Write-Err "32-bit Windows is not supported"
}
}
}
# Method 3: Try WMI query as last resort
try {
$cpu = Get-WmiObject -Class Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cpu) {
switch ($cpu.Architecture) {
9 { return "amd64" } # x64
12 { return "arm64" } # ARM64
}
}
} catch {}
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
function Resolve-LatestVersion {
+7 -2
View File
@@ -32,16 +32,21 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) {
}
}
func TestSkillCommandIsNotRegisteredInPublicOSSBuild(t *testing.T) {
func TestSkillCommandIsRegistered(t *testing.T) {
t.Parallel()
root := app.NewRootCommand()
var found bool
for _, cmd := range root.Commands() {
if cmd.Name() == "skill" {
t.Fatalf("skill command should not be registered in OSS build")
found = true
break
}
}
if !found {
t.Fatalf("skill command should be registered")
}
}
// TestSchemaJSONBootstrapOutput was removed