Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10a0599d2 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.6%"><title>coverage: 52.6%</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.6%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.6%</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 |
@@ -141,41 +141,3 @@ jobs:
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
# Trigger internal GitLab CI pipeline via webhook.
|
||||
# WUKONG_TRIGGER_TOKEN is a repository secret.
|
||||
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
|
||||
"${{ secrets.WUKONG_TRIGGER_URL }}"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
|
||||
@@ -33,8 +33,7 @@ jobs:
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
html_url: issue.html_url,
|
||||
labels: (issue.labels || []).map(label => label.name)
|
||||
html_url: issue.html_url
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,14 +28,13 @@ jobs:
|
||||
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
|
||||
const content = issue.body?.substring(0, 500) || 'No description';
|
||||
const url = issue.html_url;
|
||||
const labelsText = (issue.labels || []).map(label => label.name).join(', ') || '无标签';
|
||||
|
||||
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: 'GitHub Issue 通知',
|
||||
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n🏷️ **Labels**: ${labelsText}\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: 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`
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -37,14 +37,12 @@ jobs:
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload skills package to release
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -27,4 +27,3 @@ test/cli_compat/testdata/
|
||||
credentials*
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
GO ?= go
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -34,9 +34,6 @@ policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<img src=".github/badges/coverage.svg" alt="Coverage">
|
||||
</p>
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
- [Why dws?](#why-dws)
|
||||
- [Installation](#installation)
|
||||
- [Upgrade](#upgrade)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Using with Agents](#using-with-agents)
|
||||
@@ -86,91 +85,80 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## Upgrade
|
||||
|
||||
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
|
||||
|
||||
```bash
|
||||
dws upgrade # interactive upgrade to latest version
|
||||
dws upgrade --check # check for new versions without installing
|
||||
dws upgrade --list # list all available versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--check` | Check for updates without installing |
|
||||
| `--list` | List all available versions with changelogs |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
| `-y` | Skip confirmation prompt |
|
||||
|
||||
</details>
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
dws auth login # browser opens automatically
|
||||
dws auth login --device # for headless environments (Docker, SSH, CI)
|
||||
### Step 1: Create a DingTalk Application
|
||||
|
||||
Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
|
||||
|
||||
<details>
|
||||
<summary>View screenshot</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
### Step 2: Configure Redirect URL
|
||||
|
||||
Go to app settings → **Security Settings**. Add the following redirect URLs and save:
|
||||
|
||||
```
|
||||
http://127.0.0.1,https://login.dingtalk.com
|
||||
```
|
||||
|
||||
Select your organization and authorize. That's it.
|
||||
|
||||
> If your organization hasn't enabled CLI access, you'll be prompted to send an access request to your admin. Once approved, re-run `dws auth login`.
|
||||
> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
|
||||
|
||||
<details>
|
||||
<summary><strong>Organization hasn't enabled CLI access?</strong></summary>
|
||||
|
||||
1. After selecting your organization, click "Apply Now" to notify the admin
|
||||
2. The admin receives a request card and can approve with one click
|
||||
3. Once approved, re-run `dws auth login`
|
||||
|
||||
<summary>View screenshot</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="Apply for Access" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### Step 3: Publish the Application
|
||||
|
||||
Click "App Release - Version Management & Release" to publish and go live.
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin: Enable CLI access for your organization</strong></summary>
|
||||
|
||||
Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable.
|
||||
|
||||
<summary>View screenshot</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI Access Management" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Custom App mode (CI/CD, ISV integration)</strong></summary>
|
||||
### Step 4: Request Whitelist Access
|
||||
|
||||
For enterprise-managed scenarios, create your own DingTalk app:
|
||||
Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
|
||||
|
||||
1. [Open Platform Console](https://open-dev.dingtalk.com/fe/app#/corp/app) → Create App
|
||||
2. Security Settings → Add redirect URLs: `http://127.0.0.1,https://login.dingtalk.com`
|
||||
3. Publish the app
|
||||
4. Login:
|
||||
### Step 5: Authenticate
|
||||
|
||||
```bash
|
||||
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
```
|
||||
|
||||
Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
|
||||
Or via environment variables:
|
||||
|
||||
```bash
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Credential Configuration Priority</strong></summary>
|
||||
|
||||
`client-id` and `client-secret` support multiple configuration methods with the following priority (highest to lowest):
|
||||
|
||||
| Priority | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| 1 | CLI flags / Persisted config | `--client-id` / `--client-secret` command-line arguments; auto-saved after first successful login with `client-secret` stored in system Keychain |
|
||||
| 2 | Environment variables | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
|
||||
| 3 | Default values | Hardcoded defaults (for development only) |
|
||||
|
||||
**Recommended usage**:
|
||||
- **First login**: Use `--client-id` and `--client-secret` flags; credentials are securely persisted after successful login
|
||||
- **Subsequent use**: Run `dws` commands directly; token refresh automatically reads saved credentials from Keychain
|
||||
- **CI/CD environments**: Use environment variables
|
||||
|
||||
</details>
|
||||
|
||||
@@ -187,6 +175,13 @@ dws todo task list --dry-run # preview without executing
|
||||
|
||||
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
|
||||
|
||||
```bash
|
||||
# Configure auth via environment variables (recommended for agents, no interactive login)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### Agent Invocation Patterns
|
||||
|
||||
```bash
|
||||
@@ -339,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
|
||||
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
|
||||
| Service | Command | Tools | Subcommands | Description |
|
||||
|---------|---------|:-----:|-------------|-------------|
|
||||
| Contact | `contact` | 8 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat | `chat` | 14 | `message` `group` `bot` `search` | Group CRUD, member management, topic replies, send as user |
|
||||
| Bot | `chat bot` | 9 | — | Robot creation, group/single messaging, webhook, message recall |
|
||||
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
|
||||
| Ding | `ding` | 3 | `message` | Send/recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
|
||||
| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates |
|
||||
| Workbench | `workbench` | 2 | `app` | Batch query app details |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
|
||||
| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes |
|
||||
|
||||
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
> 104 tools across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
+69
-74
@@ -9,7 +9,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<img src=".github/badges/coverage.svg" alt="Coverage">
|
||||
</p>
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
- [为什么选择 dws?](#why-dws)
|
||||
- [安装](#安装)
|
||||
- [升级](#升级)
|
||||
- [开始使用](#开始使用)
|
||||
- [快速开始](#快速开始)
|
||||
- [在 Agent 中使用](#在-agent-中使用)
|
||||
@@ -86,91 +85,80 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 升级
|
||||
|
||||
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
|
||||
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
| `-y` | 跳过确认提示 |
|
||||
|
||||
</details>
|
||||
|
||||
## 开始使用
|
||||
|
||||
```bash
|
||||
dws auth login # 自动唤起浏览器
|
||||
dws auth login --device # 无浏览器环境(Docker、SSH、CI)
|
||||
### 步骤 1:创建钉钉应用
|
||||
|
||||
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
|
||||
|
||||
<details>
|
||||
<summary>查看截图</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
### 步骤 2:配置重定向 URL
|
||||
|
||||
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
|
||||
|
||||
```
|
||||
http://127.0.0.1,https://login.dingtalk.com
|
||||
```
|
||||
|
||||
选择组织并授权即可。
|
||||
|
||||
> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
|
||||
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
|
||||
|
||||
<details>
|
||||
<summary><strong>组织未开启 CLI 访问权限?</strong></summary>
|
||||
|
||||
1. 选择组织后,点击「立即申请」通知管理员
|
||||
2. 管理员收到申请卡片,一键审批
|
||||
3. 审批通过后,重新执行 `dws auth login`
|
||||
|
||||
<summary>查看截图</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="申请权限" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### 步骤 3:发布应用
|
||||
|
||||
点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
|
||||
|
||||
<details>
|
||||
<summary><strong>管理员:为组织开启 CLI 访问权限</strong></summary>
|
||||
|
||||
进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。
|
||||
|
||||
<summary>查看截图</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI访问管理" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>自建应用模式(CI/CD、ISV 集成)</strong></summary>
|
||||
### 步骤 4:申请白名单
|
||||
|
||||
企业自主管控场景,可创建自有钉钉应用:
|
||||
加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
|
||||
|
||||
1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用
|
||||
2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com`
|
||||
3. 发布应用
|
||||
4. 登录:
|
||||
### 步骤 5:登录认证
|
||||
|
||||
```bash
|
||||
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
```
|
||||
|
||||
首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
|
||||
或通过环境变量:
|
||||
|
||||
```bash
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>凭证配置优先级</strong></summary>
|
||||
|
||||
`client-id` 和 `client-secret` 支持多种配置方式,按以下优先级生效(从高到低):
|
||||
|
||||
| 优先级 | 配置方式 | 说明 |
|
||||
|------|----------|------|
|
||||
| 1 | CLI 参数 / 持久化配置 | `--client-id` / `--client-secret` 命令行参数;首次登录成功后自动保存,`client-secret` 存储在系统 Keychain 中 |
|
||||
| 2 | 环境变量 | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
|
||||
| 3 | 默认值 | 代码中的预设值(仅开发时使用) |
|
||||
|
||||
**推荐用法**:
|
||||
- **首次登录**:使用 `--client-id` 和 `--client-secret` 参数,登录成功后凭证会自动安全存储
|
||||
- **后续使用**:直接运行 `dws` 命令,Token 自动刷新时会从 Keychain 读取已保存的凭证
|
||||
- **CI/CD 环境**:建议使用环境变量配置
|
||||
|
||||
</details>
|
||||
|
||||
@@ -187,6 +175,13 @@ dws todo task list --dry-run # 预览操作但不执行
|
||||
|
||||
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
|
||||
|
||||
```bash
|
||||
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### Agent 调用模式
|
||||
|
||||
```bash
|
||||
@@ -339,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
| 服务 | 命令 | 工具数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
|
||||
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
|
||||
| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 |
|
||||
| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
|
||||
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
|
||||
| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 |
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
|
||||
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
|
||||
| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 |
|
||||
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 |
|
||||
|
||||
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
|
||||
> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
@@ -5,7 +5,6 @@ go 1.25.8
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -16,6 +15,7 @@ require (
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/itchyny/gojq v0.12.18 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
@@ -24,9 +24,8 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -49,9 +48,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
}
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
@@ -188,19 +185,9 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
defer cancel()
|
||||
_ = authpkg.RevokeTokenRemote(revokeCtx)
|
||||
|
||||
// Load token data to get associated clientId before deletion
|
||||
var storedClientID string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
storedClientID = tokenData.ClientID
|
||||
}
|
||||
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
@@ -209,9 +196,7 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -241,8 +226,6 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
tokenData = updatedData
|
||||
refreshed = true
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -270,9 +253,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
}
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -350,9 +331,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
@@ -30,9 +28,7 @@ func defaultConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return exeRelativeConfigDir()
|
||||
|
||||
@@ -216,10 +216,10 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
func TestRootCommandDoesNotRegisterUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
t.Fatal("upgrade command should be registered on root, but was not found")
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd != nil {
|
||||
t.Fatalf("findCommand(upgrade) = %q, want nil", cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-31
@@ -30,25 +30,16 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
if fn := edition.Get().StaticServers; fn != nil {
|
||||
injectStaticServers(fn())
|
||||
// Static servers provided by the edition hook — skip Market discovery
|
||||
// entirely. The overlay registers its own product commands via
|
||||
// RegisterExtraCommands; we only add the open-source helpers here.
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
var commands []*cobra.Command
|
||||
// Generate commands dynamically from the market discovery API.
|
||||
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
|
||||
commands = append(commands, dynamicCmds...)
|
||||
}
|
||||
@@ -56,26 +47,6 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// injectStaticServers converts edition.ServerInfo entries into
|
||||
// market.ServerDescriptor and feeds them into SetDynamicServers so the
|
||||
// direct-runtime endpoint resolver can find them.
|
||||
func injectStaticServers(servers []edition.ServerInfo) {
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
// loadDynamicCommands loads the server registry and generates CLI commands
|
||||
// dynamically from CLIOverlay metadata. It consults the disk cache first.
|
||||
// Within the short revalidation window it uses the cached registry directly;
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
|
||||
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
+19
-94
@@ -29,6 +29,7 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -40,8 +41,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -53,9 +52,7 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
totalStart := time.Now()
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
timing.PrintIfEnabled()
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
@@ -64,17 +61,12 @@ func Execute() int {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Attach timing collector to context for use by child components
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
|
||||
initStart := time.Now()
|
||||
recovery.ResetRuntimeState()
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
initDuration := time.Since(initStart)
|
||||
timing.Record("cmd_init", initDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", initDuration)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
|
||||
}
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -84,10 +76,8 @@ func Execute() int {
|
||||
|
||||
execStart := time.Now()
|
||||
executed, err := root.ExecuteC()
|
||||
execDuration := time.Since(execStart)
|
||||
timing.Record("cobra_exec", execDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", execDuration)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
|
||||
}
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
@@ -279,7 +269,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newCacheCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
schemaCmd,
|
||||
genSkillsCmd,
|
||||
@@ -289,12 +278,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
|
||||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
caller := newToolCallerAdapter(runner, flags)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
@@ -473,51 +456,24 @@ func newVersionCommand() *cobra.Command {
|
||||
Example: " dws version\n dws version --format json",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
wantJSON := cmd.Flags().Changed("format")
|
||||
if wantJSON {
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
wantJSON = (format == "json")
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read format flag")
|
||||
}
|
||||
|
||||
editionName := edition.Get().Name
|
||||
if editionName == "" {
|
||||
editionName = "open"
|
||||
payload := map[string]any{
|
||||
"version": Version(),
|
||||
"go": "1.24+",
|
||||
}
|
||||
ver := RawVersion()
|
||||
bt := BuildTime()
|
||||
gc := GitCommit()
|
||||
goVer := "1.24+"
|
||||
|
||||
arch := "MCP Dynamic Aggregation"
|
||||
|
||||
if wantJSON {
|
||||
payload := map[string]any{
|
||||
"version": ver,
|
||||
"edition": editionName,
|
||||
"architecture": arch,
|
||||
"go": goVer,
|
||||
}
|
||||
if bt != "unknown" {
|
||||
payload["build"] = bt
|
||||
}
|
||||
if gc != "unknown" {
|
||||
payload["commit"] = gc
|
||||
}
|
||||
if format == "json" {
|
||||
return output.WriteJSON(cmd.OutOrStdout(), payload)
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Version:", ver)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Edition:", editionName)
|
||||
if bt != "unknown" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Build:", bt)
|
||||
}
|
||||
if gc != "unknown" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Commit:", gc)
|
||||
}
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Architecture:", arch)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Go:", goVer)
|
||||
return nil
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"版本: %s\nGo: %s\n",
|
||||
Version(),
|
||||
"1.24+",
|
||||
)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -614,30 +570,17 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
// unless they correspond to a product discovered via dynamic server discovery
|
||||
// or listed in the edition's VisibleProducts hook.
|
||||
// unless they correspond to a product discovered via dynamic server discovery.
|
||||
// Public utility commands (auth, cache, completion, version) are always kept
|
||||
// visible; explicitly hidden commands stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var allowedProducts map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowedProducts = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowedProducts[p] = true
|
||||
}
|
||||
} else {
|
||||
allowedProducts = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowedProducts := DirectRuntimeProductIDs()
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"cache": true,
|
||||
"completion": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"recovery": true,
|
||||
"schema": true,
|
||||
"mcp": true,
|
||||
}
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
@@ -654,24 +597,6 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// deduplicateCommands removes duplicate top-level commands, keeping the last
|
||||
// registered one. This ensures overlay commands take precedence over
|
||||
// open-source defaults when both register the same product name.
|
||||
func deduplicateCommands(root *cobra.Command) {
|
||||
seen := make(map[string]*cobra.Command)
|
||||
var dups []*cobra.Command
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
if prev, ok := seen[name]; ok {
|
||||
dups = append(dups, prev)
|
||||
}
|
||||
seen[name] = cmd
|
||||
}
|
||||
for _, dup := range dups {
|
||||
root.RemoveCommand(dup)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheStoreFromEnv() *cache.Store {
|
||||
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
|
||||
return cache.NewStore(cacheDir)
|
||||
|
||||
@@ -172,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -54,16 +53,7 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowed map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowed = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowed[p] = true
|
||||
}
|
||||
} else {
|
||||
allowed = DirectRuntimeProductIDs()
|
||||
}
|
||||
allowed := DirectRuntimeProductIDs()
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
+4
-42
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -102,9 +101,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
}
|
||||
}
|
||||
|
||||
catalogStart := time.Now()
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
|
||||
if err != nil {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
@@ -135,11 +132,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
authStart := time.Now()
|
||||
authToken := r.resolveAuthToken(ctx)
|
||||
authDuration := time.Since(authStart)
|
||||
RecordTiming(ctx, "auth_token", authDuration)
|
||||
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
|
||||
}
|
||||
|
||||
if invocation.DryRun {
|
||||
@@ -171,32 +166,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fail-fast: reject unauthenticated requests before making network calls.
|
||||
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"未登录,请先执行 dws auth login",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
|
||||
callDuration := time.Since(callStart)
|
||||
RecordTiming(ctx, "mcp_call", callDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
|
||||
}
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
_ = fn(defaultConfigDir(), err)
|
||||
}
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
@@ -272,10 +247,8 @@ func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() {
|
||||
loadDuration := time.Since(loadStart)
|
||||
RecordTiming(ctx, "keychain_load", loadDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", loadDuration)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -341,14 +314,6 @@ func runtimeFlagEnabled(raw string, defaultValue bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Category == apperrors.CategoryAuth
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productEndpointOverride(productID string) (string, bool) {
|
||||
key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL"
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
@@ -379,9 +344,6 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -90,7 +90,7 @@ func TestRuntimeRunnerBlocksUnsafeContentWhenEnforced(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -121,7 +121,7 @@ func TestCanonicalCommandUsesRuntimeRunnerWhenEnabled(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes", "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -255,37 +255,6 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
// No --token flag, should be rejected
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want authentication error")
|
||||
}
|
||||
|
||||
// Verify we get a clear auth error, not a cryptic HTTP 400
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "未登录") {
|
||||
t.Fatalf("Execute() error = %v, want error containing '未登录'", err)
|
||||
}
|
||||
if !strings.Contains(errMsg, "auth login") {
|
||||
t.Fatalf("Execute() error = %v, want error containing 'auth login'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
@@ -446,7 +415,7 @@ func TestCanonicalSensitiveToolAcceptsInteractiveConfirmation(t *testing.T) {
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetIn(strings.NewReader("yes\n"))
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -496,7 +465,7 @@ func TestRuntimeRunnerUsesProductEndpointOverride(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -659,7 +628,7 @@ func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable to enable performance timing output.
|
||||
const PerfTimingEnv = "DWS_PERF_TIMING"
|
||||
|
||||
// timingContextKey is the context key for TimingCollector.
|
||||
type timingContextKey struct{}
|
||||
|
||||
// TimingEntry represents a single timing measurement.
|
||||
type TimingEntry struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
Timestamp time.Time
|
||||
Seq int // insertion order
|
||||
}
|
||||
|
||||
// TimingCollector collects timing measurements for a single command execution.
|
||||
// It is safe for concurrent use.
|
||||
type TimingCollector struct {
|
||||
mu sync.Mutex
|
||||
start time.Time
|
||||
entries []TimingEntry
|
||||
seq int
|
||||
}
|
||||
|
||||
// NewTimingCollector creates a new collector with the start time set to now.
|
||||
func NewTimingCollector() *TimingCollector {
|
||||
return &TimingCollector{
|
||||
start: time.Now(),
|
||||
entries: make([]TimingEntry, 0, 16),
|
||||
}
|
||||
}
|
||||
|
||||
// Record adds a timing entry with the given name and duration.
|
||||
func (tc *TimingCollector) Record(name string, d time.Duration) {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
tc.entries = append(tc.entries, TimingEntry{
|
||||
Name: name,
|
||||
Duration: d,
|
||||
Timestamp: time.Now(),
|
||||
Seq: tc.seq,
|
||||
})
|
||||
tc.seq++
|
||||
}
|
||||
|
||||
// StartTimer returns a function that, when called, records the elapsed time
|
||||
// since StartTimer was called. This is convenient for defer usage:
|
||||
//
|
||||
// defer tc.StartTimer("operation")()
|
||||
func (tc *TimingCollector) StartTimer(name string) func() {
|
||||
if tc == nil {
|
||||
return func() {}
|
||||
}
|
||||
start := time.Now()
|
||||
return func() {
|
||||
tc.Record(name, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
// Total returns the total elapsed time since the collector was created.
|
||||
func (tc *TimingCollector) Total() time.Duration {
|
||||
if tc == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Since(tc.start)
|
||||
}
|
||||
|
||||
// Entries returns a copy of all recorded entries in insertion order.
|
||||
func (tc *TimingCollector) Entries() []TimingEntry {
|
||||
if tc == nil {
|
||||
return nil
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
result := make([]TimingEntry, len(tc.entries))
|
||||
copy(result, tc.entries)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Seq < result[j].Seq
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Print writes a summary of all timing entries to the given writer.
|
||||
func (tc *TimingCollector) Print(w io.Writer) {
|
||||
if tc == nil || w == nil {
|
||||
return
|
||||
}
|
||||
entries := tc.Entries()
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintf(w, "\n[Timing] Total: %v (no detailed entries)\n", tc.Total().Truncate(time.Millisecond))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "[Timing] Execution breakdown:")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, e.Duration.Truncate(time.Millisecond))
|
||||
}
|
||||
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", tc.Total().Truncate(time.Millisecond))
|
||||
}
|
||||
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_TIMING is set.
|
||||
func (tc *TimingCollector) PrintIfEnabled() {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv(PerfTimingEnv) == "" {
|
||||
return
|
||||
}
|
||||
tc.Print(os.Stderr)
|
||||
}
|
||||
|
||||
// WithTimingCollector returns a new context with the TimingCollector attached.
|
||||
func WithTimingCollector(ctx context.Context, tc *TimingCollector) context.Context {
|
||||
return context.WithValue(ctx, timingContextKey{}, tc)
|
||||
}
|
||||
|
||||
// TimingCollectorFromContext extracts the TimingCollector from context, or nil.
|
||||
func TimingCollectorFromContext(ctx context.Context) *TimingCollector {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
tc, _ := ctx.Value(timingContextKey{}).(*TimingCollector)
|
||||
return tc
|
||||
}
|
||||
|
||||
// RecordTiming is a convenience function to record timing to the collector in context.
|
||||
func RecordTiming(ctx context.Context, name string, d time.Duration) {
|
||||
if tc := TimingCollectorFromContext(ctx); tc != nil {
|
||||
tc.Record(name, d)
|
||||
}
|
||||
}
|
||||
|
||||
// StartTiming is a convenience function that returns a stop function for defer usage.
|
||||
// Example:
|
||||
//
|
||||
// defer StartTiming(ctx, "operation")()
|
||||
func StartTiming(ctx context.Context, name string) func() {
|
||||
tc := TimingCollectorFromContext(ctx)
|
||||
if tc == nil {
|
||||
return func() {}
|
||||
}
|
||||
return tc.StartTimer(name)
|
||||
}
|
||||
|
||||
// IsPerfTimingEnabled returns true if performance timing output is enabled.
|
||||
func IsPerfTimingEnabled() bool {
|
||||
return os.Getenv(PerfTimingEnv) != ""
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimingCollector_Basic(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
if tc == nil {
|
||||
t.Fatal("NewTimingCollector returned nil")
|
||||
}
|
||||
|
||||
// Record some timings
|
||||
tc.Record("op1", 10*time.Millisecond)
|
||||
tc.Record("op2", 20*time.Millisecond)
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Check ordering
|
||||
if entries[0].Name != "op1" {
|
||||
t.Errorf("expected first entry to be 'op1', got %q", entries[0].Name)
|
||||
}
|
||||
if entries[1].Name != "op2" {
|
||||
t.Errorf("expected second entry to be 'op2', got %q", entries[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_StartTimer(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
|
||||
stop := tc.StartTimer("timed_op")
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
stop()
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Name != "timed_op" {
|
||||
t.Errorf("expected entry name 'timed_op', got %q", entries[0].Name)
|
||||
}
|
||||
if entries[0].Duration < 5*time.Millisecond {
|
||||
t.Errorf("expected duration >= 5ms, got %v", entries[0].Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_NilSafe(t *testing.T) {
|
||||
var tc *TimingCollector
|
||||
|
||||
// Should not panic on nil collector
|
||||
tc.Record("op", 10*time.Millisecond)
|
||||
stop := tc.StartTimer("op")
|
||||
stop()
|
||||
_ = tc.Total()
|
||||
_ = tc.Entries()
|
||||
tc.Print(nil)
|
||||
tc.PrintIfEnabled()
|
||||
}
|
||||
|
||||
func TestTimingCollector_Print(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("auth_token", 44*time.Millisecond)
|
||||
tc.Record("mcp_call", 150*time.Millisecond)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tc.Print(&buf)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "[Timing]") {
|
||||
t.Error("output should contain [Timing] header")
|
||||
}
|
||||
if !strings.Contains(output, "auth_token") {
|
||||
t.Error("output should contain 'auth_token'")
|
||||
}
|
||||
if !strings.Contains(output, "mcp_call") {
|
||||
t.Error("output should contain 'mcp_call'")
|
||||
}
|
||||
if !strings.Contains(output, "Total") {
|
||||
t.Error("output should contain 'Total'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_PrintIfEnabled(t *testing.T) {
|
||||
// Set environment variable
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("test_op", 10*time.Millisecond)
|
||||
|
||||
// This should not panic and should print to stderr
|
||||
tc.PrintIfEnabled()
|
||||
}
|
||||
|
||||
func TestTimingCollector_ContextIntegration(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
ctx := WithTimingCollector(context.Background(), tc)
|
||||
|
||||
// Retrieve from context
|
||||
retrieved := TimingCollectorFromContext(ctx)
|
||||
if retrieved != tc {
|
||||
t.Error("TimingCollectorFromContext should return the same collector")
|
||||
}
|
||||
|
||||
// Use convenience functions
|
||||
RecordTiming(ctx, "ctx_op", 30*time.Millisecond)
|
||||
stop := StartTiming(ctx, "ctx_timed")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
stop()
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollectorFromContext_NilContext(t *testing.T) {
|
||||
tc := TimingCollectorFromContext(nil)
|
||||
if tc != nil {
|
||||
t.Error("TimingCollectorFromContext(nil) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollectorFromContext_NoCollector(t *testing.T) {
|
||||
tc := TimingCollectorFromContext(context.Background())
|
||||
if tc != nil {
|
||||
t.Error("TimingCollectorFromContext with no collector should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTiming_NoCollector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
stop := StartTiming(ctx, "no_collector")
|
||||
// Should not panic
|
||||
stop()
|
||||
}
|
||||
|
||||
func TestIsPerfTimingEnabled(t *testing.T) {
|
||||
// Clear the env var first
|
||||
os.Unsetenv(PerfTimingEnv)
|
||||
|
||||
if IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return false when env var is not set")
|
||||
}
|
||||
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
|
||||
if !IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return true when env var is set")
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller
|
||||
// interface so that private overlays can invoke MCP tools without importing
|
||||
// internal packages.
|
||||
type toolCallerAdapter struct {
|
||||
runner executor.Runner
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
|
||||
return &toolCallerAdapter{runner: runner, flags: flags}
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
result, err := a.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a.flags != nil {
|
||||
return a.flags.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
return &edition.ToolResult{}
|
||||
}
|
||||
|
||||
// The runtime runner stores MCP response content under "content".
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
|
||||
// Content may be a []any of {type, text} blocks from the MCP response,
|
||||
// or a single map for mock mode.
|
||||
switch v := contentRaw.(type) {
|
||||
case []any:
|
||||
blocks := make([]edition.ContentBlock, 0, len(v))
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
blocks = append(blocks, edition.ContentBlock{
|
||||
Type: strVal(m, "type"),
|
||||
Text: strVal(m, "text"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ugBold = color.New(color.Bold).SprintFunc()
|
||||
ugGreen = color.New(color.FgGreen).SprintFunc()
|
||||
ugYellow = color.New(color.FgYellow).SprintFunc()
|
||||
ugRed = color.New(color.FgRed).SprintFunc()
|
||||
ugCyan = color.New(color.FgCyan).SprintFunc()
|
||||
ugDim = color.New(color.Faint).SprintFunc()
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
)
|
||||
|
||||
func newUpgradeCommand() *cobra.Command {
|
||||
var (
|
||||
flagCheck bool
|
||||
flagList bool
|
||||
flagVersion string
|
||||
flagRollback bool
|
||||
flagForce bool
|
||||
flagSkipSkills bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "升级 DWS CLI 到最新版本",
|
||||
Long: `检查并升级 DWS CLI 到最新版本。
|
||||
|
||||
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
return runUpgradeList(cmd, format)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
}
|
||||
if flagCheck {
|
||||
return runUpgradeCheck(cmd, format)
|
||||
}
|
||||
return runUpgrade(cmd.Context(), upgradeOptions{
|
||||
targetVersion: flagVersion,
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出所有可用版本")
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type upgradeOptions struct {
|
||||
targetVersion string
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
|
||||
func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
}
|
||||
|
||||
latest, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
|
||||
|
||||
if format == "json" {
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"current_version": ensureV(currentVer),
|
||||
"latest_version": "v" + latest.Version,
|
||||
"needs_upgrade": needsUpgrade,
|
||||
"release_date": latest.Date,
|
||||
"prerelease": latest.Prerelease,
|
||||
"changelog": parseChangelogEntries(latest.Changelog, 10),
|
||||
"release_url": latest.HTMLURL,
|
||||
})
|
||||
}
|
||||
|
||||
if !needsUpgrade {
|
||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+latest.Version))
|
||||
if latest.Date != "" {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
|
||||
}
|
||||
if latest.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
|
||||
fmt.Printf(" %s\n", ugBold("更新内容:"))
|
||||
for _, e := range entries {
|
||||
fmt.Printf(" %s %s\n", ugGreen("•"), e)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade --list ---
|
||||
|
||||
func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
|
||||
}
|
||||
|
||||
versions, err := client.FetchAllReleases()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
currentVer := strings.TrimPrefix(version, "v")
|
||||
|
||||
if format == "json" {
|
||||
var items []map[string]any
|
||||
for _, v := range versions {
|
||||
items = append(items, map[string]any{
|
||||
"version": "v" + v.Version,
|
||||
"date": v.Date,
|
||||
"prerelease": v.Prerelease,
|
||||
"installed": v.Version == currentVer,
|
||||
"changelog": parseChangelogEntries(v.Changelog, 10),
|
||||
})
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"current_version": ensureV(version),
|
||||
"versions": items,
|
||||
})
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugBold(fmt.Sprintf("%-12s %-12s %-12s %s", "VERSION", "DATE", "TYPE", "CHANGELOG")))
|
||||
fmt.Printf(" %s\n", ugDim(strings.Repeat("─", 70)))
|
||||
|
||||
for _, v := range versions {
|
||||
releaseType := ugGreen("stable")
|
||||
if v.Prerelease {
|
||||
releaseType = ugYellow("pre-release")
|
||||
}
|
||||
versionStr := fmt.Sprintf("v%-11s", v.Version)
|
||||
marker := ""
|
||||
if v.Version == currentVer {
|
||||
versionStr = ugBoldGrn(versionStr)
|
||||
marker = ugCyan(" ← 已安装")
|
||||
}
|
||||
changelog := ugDim(truncateChangelogForList(v.Changelog, 40))
|
||||
fmt.Printf(" %s %-12s %-23s %s%s\n", versionStr, v.Date, releaseType, changelog, marker)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade --rollback ---
|
||||
|
||||
func runUpgradeRollback(yes bool) error {
|
||||
rm := upgrade.NewRollbackManager()
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取备份列表失败: %w", err)
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
return fmt.Errorf("没有可用的备份,无法回滚")
|
||||
}
|
||||
|
||||
target := backups[0]
|
||||
targetVer := ensureV(target.Version)
|
||||
currentVer := ensureV(version)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" 当前版本: %s\n", ugBold(currentVer))
|
||||
fmt.Printf(" 回滚目标: %s %s\n", ugCyan(targetVer), ugDim("("+target.CreatedAt.Format("2006-01-02 15:04")+")"))
|
||||
|
||||
if !yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否回滚到 %s? [y/N] ", ugBold(targetVer))
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
if answer != "y" && answer != "Y" {
|
||||
fmt.Println("已取消")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Print(" 回滚中...")
|
||||
if err := rm.RollbackTo(target); err != nil {
|
||||
return fmt.Errorf("\n回滚失败: %w", err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Printf(" %s 已回滚 %s %s %s\n", ugBoldGrn("✔"), ugDim(currentVer), ugBold("→"), ugBoldGrn(targetVer))
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade (full) ---
|
||||
//
|
||||
// The upgrade flow is split into two phases for atomicity:
|
||||
// Phase 1 (Prepare): download, verify, extract — all in a temp directory, zero side effects.
|
||||
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
|
||||
// If anything fails in Phase 1, no files on disk are modified.
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
|
||||
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
|
||||
return fmt.Errorf("初始化目录结构失败: %w", err)
|
||||
}
|
||||
|
||||
upgrade.CleanupStaleFiles()
|
||||
|
||||
client := upgrade.NewClient()
|
||||
var release *upgrade.ReleaseInfo
|
||||
var err error
|
||||
|
||||
if opts.targetVersion != "" {
|
||||
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
|
||||
release, err = client.FetchReleaseByTag(opts.targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
|
||||
}
|
||||
} else {
|
||||
release, err = client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
|
||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
|
||||
if release.Date != "" {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
|
||||
}
|
||||
if release.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否升级? [y/N] ")
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
if answer != "y" && answer != "Y" {
|
||||
fmt.Println("已取消")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
||||
if err != nil {
|
||||
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
|
||||
const totalSteps = 5
|
||||
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
|
||||
|
||||
// ========================================================================
|
||||
// Phase 1: Prepare (download + verify + extract — no side effects)
|
||||
// ========================================================================
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// --- Step 1: Backup ---
|
||||
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
|
||||
rm := upgrade.NewRollbackManager()
|
||||
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
|
||||
if backupErr != nil {
|
||||
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
|
||||
} else {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Fetch checksums.txt (needed for strict verification of both binary and skills)
|
||||
var checksumsContent string
|
||||
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
|
||||
if checksumsAsset != nil {
|
||||
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
||||
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
||||
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
|
||||
checksumsContent = string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: Download (binary + skills together) ---
|
||||
sl := stepFmt(2)
|
||||
progressPrefix := fmt.Sprintf(" %s 下载 %s", sl, ugCyan(binaryAsset.Name))
|
||||
fmt.Print(progressPrefix)
|
||||
start := time.Now()
|
||||
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
|
||||
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
||||
func(percent float64, downloaded, total int64) {
|
||||
bar := progressBar(percent)
|
||||
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("下载二进制失败: %w", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
clearLine := strings.Repeat(" ", 100)
|
||||
|
||||
var skillsZipPath string
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||
|
||||
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
|
||||
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
|
||||
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包下载失败: %w", dlErr)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
} else {
|
||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||
}
|
||||
|
||||
// --- Step 3: Verify SHA256 (binary + skills together) ---
|
||||
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Extract + validate ---
|
||||
fmt.Printf(" %s 解压并验证...", stepFmt(4))
|
||||
extractDir := filepath.Join(tmpDir, "extracted")
|
||||
if strings.HasSuffix(binaryAsset.Name, ".zip") {
|
||||
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
}
|
||||
binaryPath := upgrade.FindBinaryInDir(extractDir)
|
||||
if binaryPath == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
|
||||
}
|
||||
if err := validateNewBinary(binaryPath, release.Version); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("验证失败: %w", err)
|
||||
}
|
||||
|
||||
var skillSrc string
|
||||
if hasSkills {
|
||||
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
|
||||
os.MkdirAll(skillsExtractDir, 0755)
|
||||
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
|
||||
}
|
||||
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
|
||||
if skillSrc == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
|
||||
// ========================================================================
|
||||
// Phase 2: Apply (all preparations succeeded — now do the actual changes)
|
||||
// ========================================================================
|
||||
|
||||
// --- Step 5: Replace binary + install skills ---
|
||||
fmt.Printf(" %s 替换并安装...", stepFmt(5))
|
||||
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("替换二进制失败: %w", err)
|
||||
}
|
||||
|
||||
if hasSkills {
|
||||
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
|
||||
if installErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||
}
|
||||
failed := result.Failed()
|
||||
if len(failed) > 0 {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
for _, d := range failed {
|
||||
fmt.Printf(" %s %s %s\n", ugRed("✗"), shortenHome(d.Dir), ugDim(d.Err.Error()))
|
||||
}
|
||||
return fmt.Errorf("技能包安装到 %d 个目录失败,请检查权限后手动重试: dws upgrade --force", len(failed))
|
||||
}
|
||||
succeeded := result.Succeeded()
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("二进制已替换"))
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim(fmt.Sprintf("技能包已安装 (%d 个位置)", len(succeeded))))
|
||||
for _, d := range succeeded {
|
||||
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
rm.Cleanup(5)
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Printf(" %s 升级完成 %s %s %s\n", ugBoldGrn("✔"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
|
||||
fmt.Printf(" %s\n", ugDim("如遇问题,运行 dws upgrade --rollback 回滚"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictVerifyFile performs SHA256 verification with strict semantics:
|
||||
// - If checksum info is available and matches → ✓
|
||||
// - If checksum info is available but MISMATCHES → error (abort upgrade)
|
||||
// - If no checksum info at all → skip (no data to compare against)
|
||||
func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent string) error {
|
||||
fmt.Printf(" %s 校验 %s...", label, fileName)
|
||||
|
||||
// Source 1: checksums.txt
|
||||
if checksumsContent != "" {
|
||||
checksums := upgrade.ParseChecksumFile(checksumsContent)
|
||||
if expectedHash, ok := checksums[fileName]; ok {
|
||||
if err := upgrade.VerifySHA256(filePath, expectedHash); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: GitHub asset digest
|
||||
if digest := upgrade.ExtractDigestSHA256(assetDigest); digest != "" {
|
||||
if err := upgrade.VerifySHA256(filePath, digest); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// No checksum info available at all
|
||||
fmt.Printf(" %s\n", ugDim("- 跳过 (无可用校验信息)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNewBinary checks the downloaded binary is valid.
|
||||
func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("文件不存在: %w", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("文件为空")
|
||||
}
|
||||
if err := os.Chmod(binaryPath, 0755); err != nil {
|
||||
return fmt.Errorf("设置执行权限失败: %w", err)
|
||||
}
|
||||
|
||||
// Try running the binary
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("二进制无法执行: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(out), expectedVersion) {
|
||||
// Not fatal, version format might differ
|
||||
fmt.Printf("\n 注意: 版本输出中未包含 %s", expectedVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz extracts a .tar.gz file using the system tar command.
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
os.MkdirAll(destDir, 0755)
|
||||
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func progressBar(percent float64) string {
|
||||
width := 20
|
||||
filled := int(percent / 100 * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||
}
|
||||
|
||||
// parseChangelogEntries extracts human-readable commit messages from a
|
||||
// GitHub Release body. The body typically looks like:
|
||||
//
|
||||
// ## Changelog
|
||||
// * abcdef1234 - some commit message
|
||||
// * 0123456789 Merge branch 'main' into main
|
||||
//
|
||||
// We strip the hash prefix and skip noisy entries (Merge branch, Merge pull request).
|
||||
func parseChangelogEntries(body string, maxEntries int) []string {
|
||||
var entries []string
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
line = strings.TrimPrefix(line, "- ")
|
||||
|
||||
msg := stripCommitHash(line)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
if isNoiseCommit(msg) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, msg)
|
||||
if maxEntries > 0 && len(entries) >= maxEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// truncateChangelog returns a short one-line summary for the --check output.
|
||||
func truncateChangelog(body string) string {
|
||||
entries := parseChangelogEntries(body, 3)
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(entries, "; ")
|
||||
}
|
||||
|
||||
// truncateChangelogForList returns a compact summary for the --list table.
|
||||
func truncateChangelogForList(body string, maxLen int) string {
|
||||
entries := parseChangelogEntries(body, 2)
|
||||
if len(entries) == 0 {
|
||||
return "-"
|
||||
}
|
||||
summary := strings.Join(entries, "; ")
|
||||
if len(summary) > maxLen {
|
||||
return summary[:maxLen-3] + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// stripCommitHash removes a leading Git commit hash (7-40 hex chars)
|
||||
// and optional separator (" - ", " ") from a line.
|
||||
func stripCommitHash(line string) string {
|
||||
if len(line) < 8 {
|
||||
return line
|
||||
}
|
||||
// Check if line starts with hex chars (commit hash)
|
||||
hashEnd := 0
|
||||
for hashEnd < len(line) && hashEnd < 40 {
|
||||
c := line[hashEnd]
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
|
||||
hashEnd++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hashEnd < 7 {
|
||||
return line
|
||||
}
|
||||
rest := line[hashEnd:]
|
||||
rest = strings.TrimPrefix(rest, " - ")
|
||||
rest = strings.TrimLeft(rest, " ")
|
||||
return rest
|
||||
}
|
||||
|
||||
func isNoiseCommit(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
noisePatterns := []string{
|
||||
"merge branch",
|
||||
"merge pull request",
|
||||
"merge remote-tracking",
|
||||
}
|
||||
for _, p := range noisePatterns {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureV ensures a version string has a "v" prefix for display consistency.
|
||||
// Non-semver values like "dev" or "unknown" are returned as-is.
|
||||
func ensureV(ver string) string {
|
||||
if ver == "" {
|
||||
return "v0.0.0"
|
||||
}
|
||||
if strings.HasPrefix(ver, "v") {
|
||||
return ver
|
||||
}
|
||||
// Only add "v" prefix for semver-like strings (starts with digit)
|
||||
if len(ver) > 0 && ver[0] >= '0' && ver[0] <= '9' {
|
||||
return "v" + ver
|
||||
}
|
||||
return ver
|
||||
}
|
||||
|
||||
// resolveUpgradeFormat returns "json" only when the user explicitly passes -f json.
|
||||
// Unlike other commands, upgrade defaults to table (human-friendly) output.
|
||||
func resolveUpgradeFormat(cmd *cobra.Command) string {
|
||||
pf := cmd.Root().PersistentFlags()
|
||||
if pf.Changed("format") {
|
||||
if f, err := pf.GetString("format"); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(f))
|
||||
}
|
||||
}
|
||||
return "table"
|
||||
}
|
||||
|
||||
func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
func shortenHome(path string) string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
return "~" + path[len(homeDir):]
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- ensureV ---
|
||||
|
||||
func TestEnsureV(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"1.0.6", "v1.0.6"},
|
||||
{"v1.0.6", "v1.0.6"},
|
||||
{"0.0.1", "v0.0.1"},
|
||||
{"dev", "dev"},
|
||||
{"unknown", "unknown"},
|
||||
{"", "v0.0.0"},
|
||||
{"v", "v"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ensureV(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("ensureV(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseChangelogEntries ---
|
||||
|
||||
func TestParseChangelogEntries(t *testing.T) {
|
||||
body := `## Changelog
|
||||
* abcdef1234567 - fix login bug
|
||||
* 0123456789abc Merge branch 'main' into main
|
||||
* fedcba9876543 - add upgrade command
|
||||
* deadbeef12345 Merge pull request #42
|
||||
* 1234567890abc - improve error handling
|
||||
`
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("len(entries) = %d, want 3 (merge commits should be filtered)", len(entries))
|
||||
}
|
||||
if entries[0] != "fix login bug" {
|
||||
t.Errorf("entries[0] = %q, want %q", entries[0], "fix login bug")
|
||||
}
|
||||
if entries[1] != "add upgrade command" {
|
||||
t.Errorf("entries[1] = %q, want %q", entries[1], "add upgrade command")
|
||||
}
|
||||
if entries[2] != "improve error handling" {
|
||||
t.Errorf("entries[2] = %q, want %q", entries[2], "improve error handling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_MaxLimit(t *testing.T) {
|
||||
body := "* abc1234 - msg1\n* def5678 - msg2\n* ghi9012 - msg3\n"
|
||||
entries := parseChangelogEntries(body, 2)
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("len = %d, want 2 (should respect maxEntries)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_EmptyBody(t *testing.T) {
|
||||
entries := parseChangelogEntries("", 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 for empty body", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_OnlyHeaders(t *testing.T) {
|
||||
body := "## Changelog\n## Another heading\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 for headers-only body", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_OnlyMergeCommits(t *testing.T) {
|
||||
body := "* abc1234 Merge branch 'main'\n* def5678 Merge pull request #10\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 (all merge commits should be filtered)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_DashPrefixedLines(t *testing.T) {
|
||||
body := "- fix bug\n- add feature\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0] != "fix bug" {
|
||||
t.Errorf("entries[0] = %q, want %q", entries[0], "fix bug")
|
||||
}
|
||||
}
|
||||
|
||||
// --- stripCommitHash ---
|
||||
|
||||
func TestStripCommitHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"abcdef1234567 - fix bug", "fix bug"},
|
||||
{"abcdef1234567 fix bug", "fix bug"},
|
||||
{"short", "short"}, // too short to be a hash
|
||||
{"abc123", "abc123"}, // less than 7 hex chars
|
||||
{"no hash here", "no hash here"},
|
||||
{"ABCDEF1234567 - upper case hash", "upper case hash"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripCommitHash(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripCommitHash(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- isNoiseCommit ---
|
||||
|
||||
func TestIsNoiseCommit(t *testing.T) {
|
||||
tests := []struct {
|
||||
msg string
|
||||
want bool
|
||||
}{
|
||||
{"Merge branch 'main'", true},
|
||||
{"merge branch 'develop'", true},
|
||||
{"Merge pull request #42", true},
|
||||
{"Merge remote-tracking branch 'origin/main'", true},
|
||||
{"fix login bug", false},
|
||||
{"add new feature", false},
|
||||
{"merge conflicts resolved", false}, // doesn't start with "merge branch"
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isNoiseCommit(tt.msg)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNoiseCommit(%q) = %v, want %v", tt.msg, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- truncateChangelog ---
|
||||
|
||||
func TestTruncateChangelog(t *testing.T) {
|
||||
body := "## Changelog\n* abc1234 - fix A\n* def5678 - fix B\n* ghi9012 - fix C\n* jkl3456 - fix D\n"
|
||||
result := truncateChangelog(body)
|
||||
if result == "" {
|
||||
t.Error("truncateChangelog returned empty")
|
||||
}
|
||||
// Should contain max 3 entries separated by "; "
|
||||
parts := strings.Split(result, "; ")
|
||||
if len(parts) > 3 {
|
||||
t.Errorf("truncateChangelog should have at most 3 entries, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateChangelog_EmptyBody(t *testing.T) {
|
||||
if got := truncateChangelog(""); got != "" {
|
||||
t.Errorf("truncateChangelog('') = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- truncateChangelogForList ---
|
||||
|
||||
func TestTruncateChangelogForList(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"", 40, "-"},
|
||||
{"## Changelog\n", 40, "-"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := truncateChangelogForList(tt.body, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateChangelogForList(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateChangelogForList_Truncation(t *testing.T) {
|
||||
body := "* abc1234 - a very long commit message that should be truncated eventually\n"
|
||||
result := truncateChangelogForList(body, 20)
|
||||
if len(result) > 20 {
|
||||
t.Errorf("result len = %d, want <= 20", len(result))
|
||||
}
|
||||
if !strings.HasSuffix(result, "...") {
|
||||
t.Errorf("truncated result should end with '...' , got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// --- progressBar ---
|
||||
|
||||
func TestProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
percent float64
|
||||
filled int
|
||||
}{
|
||||
{0, 0},
|
||||
{50, 10},
|
||||
{100, 20},
|
||||
{150, 20}, // capped
|
||||
}
|
||||
for _, tt := range tests {
|
||||
bar := progressBar(tt.percent)
|
||||
if len(bar) != 20*len("█") && len(bar) != 20*len("░") {
|
||||
// Since multi-byte chars, just check total rune count
|
||||
runes := []rune(bar)
|
||||
if len(runes) != 20 {
|
||||
t.Errorf("progressBar(%v) rune count = %d, want 20", tt.percent, len(runes))
|
||||
}
|
||||
}
|
||||
filledCount := strings.Count(bar, "█")
|
||||
if filledCount != tt.filled {
|
||||
t.Errorf("progressBar(%v) filled = %d, want %d", tt.percent, filledCount, tt.filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortenHome ---
|
||||
|
||||
func TestShortenHome(t *testing.T) {
|
||||
// Non-home path should be unchanged
|
||||
got := shortenHome("/tmp/somewhere")
|
||||
if got != "/tmp/somewhere" {
|
||||
t.Errorf("shortenHome(/tmp/somewhere) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveUpgradeFormat ---
|
||||
|
||||
func TestResolveUpgradeFormat_Default(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
// format not changed => should default to "table" for upgrade
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "table" {
|
||||
t.Errorf("resolveUpgradeFormat(unchanged) = %q, want %q", got, "table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUpgradeFormat_ExplicitJSON(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
// Simulate user explicitly setting format
|
||||
root.PersistentFlags().Set("format", "json")
|
||||
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "json" {
|
||||
t.Errorf("resolveUpgradeFormat(explicit json) = %q, want %q", got, "json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUpgradeFormat_ExplicitTable(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
root.PersistentFlags().Set("format", "table")
|
||||
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "table" {
|
||||
t.Errorf("resolveUpgradeFormat(explicit table) = %q, want %q", got, "table")
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeJSON ---
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
data := map[string]any{
|
||||
"version": "v1.0.6",
|
||||
"ok": true,
|
||||
}
|
||||
if err := writeJSON(&buf, data); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"version": "v1.0.6"`) {
|
||||
t.Errorf("output missing version: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, `"ok": true`) {
|
||||
t.Errorf("output missing ok: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- strictVerifyFile ---
|
||||
|
||||
func TestStrictVerifyFile_MatchesChecksums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
content := []byte("valid binary content")
|
||||
os.WriteFile(filePath, content, 0644)
|
||||
|
||||
hash := computeTestSHA256(t, content)
|
||||
checksums := hash + " test.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
|
||||
if err != nil {
|
||||
t.Errorf("expected success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_ChecksumMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("tampered content"), 0644)
|
||||
|
||||
checksums := "0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for checksum mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "校验失败") {
|
||||
t.Errorf("error = %q, want to contain '校验失败'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_DigestMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("tampered"), 0644)
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz",
|
||||
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for digest mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_NoChecksumInfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("content"), 0644)
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("no checksum info should skip, not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_FileNotInChecksums_FallsToDigest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "skills.zip")
|
||||
content := []byte("skills content")
|
||||
os.WriteFile(filePath, content, 0644)
|
||||
|
||||
hash := computeTestSHA256(t, content)
|
||||
// checksums.txt has entries but NOT skills.zip
|
||||
checksums := "abcdef1234567890 other-file.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "skills.zip", "sha256:"+hash, checksums)
|
||||
if err != nil {
|
||||
t.Errorf("should fall through to digest and succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func computeTestSHA256(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// --- newUpgradeCommand ---
|
||||
|
||||
func TestNewUpgradeCommand_Flags(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
|
||||
if cmd.Use != "upgrade" {
|
||||
t.Errorf("Use = %q, want upgrade", cmd.Use)
|
||||
}
|
||||
|
||||
expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
|
||||
for _, name := range expectedFlags {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("missing flag: --%s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUpgradeCommand_NoArgs(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
// Simulate passing positional args - should error with cobra.NoArgs
|
||||
cmd.SetArgs([]string{"rollback"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for positional args (NoArgs)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
cmd.Execute()
|
||||
help := buf.String()
|
||||
|
||||
if !strings.Contains(help, "upgrade") {
|
||||
t.Error("help should contain 'upgrade'")
|
||||
}
|
||||
if !strings.Contains(help, "--check") {
|
||||
t.Error("help should contain --check")
|
||||
}
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
}
|
||||
+1
-25
@@ -13,22 +13,7 @@
|
||||
|
||||
package app
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// SetVersion overrides the version, build time and git commit strings.
|
||||
// Called by pkg/cli.SetVersion for overlay modules that inject their own
|
||||
// version info via ldflags.
|
||||
func SetVersion(v, bt, gc string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
if bt != "" {
|
||||
buildTime = bt
|
||||
}
|
||||
if gc != "" {
|
||||
gitCommit = gc
|
||||
}
|
||||
}
|
||||
var version = "v1.0.6"
|
||||
|
||||
// Version returns the current CLI version string, including build metadata
|
||||
// when injected via ldflags (buildTime, gitCommit).
|
||||
@@ -38,12 +23,3 @@ func Version() string {
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// RawVersion returns the bare version string without build metadata.
|
||||
func RawVersion() string { return version }
|
||||
|
||||
// BuildTime returns the build timestamp injected via ldflags.
|
||||
func BuildTime() string { return buildTime }
|
||||
|
||||
// GitCommit returns the git commit hash injected via ldflags.
|
||||
func GitCommit() string { return gitCommit }
|
||||
|
||||
@@ -1,840 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setupMCPConfigDir creates a temp config dir with mcp_url pointing to the
|
||||
// given test server and sets DWS_CONFIG_DIR via t.Setenv.
|
||||
// NOTE: tests calling this must NOT use t.Parallel().
|
||||
func setupMCPConfigDir(t *testing.T, srvURL string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "mcp_url"), []byte(srvURL), 0o600)
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// resetClientIDFromMCP clears the MCP-sourced flag (test helper).
|
||||
func resetClientIDFromMCP() {
|
||||
clientMu.Lock()
|
||||
defer clientMu.Unlock()
|
||||
clientIDFromMCP = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. CheckCLIAuthEnabled: interface error → fail-closed with retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CheckCLIAuthEnabled when server returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Server 500 → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
configDir := setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 2 * time.Second},
|
||||
}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ Connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{this is not valid json}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Timeout_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 200 * time.Millisecond},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(ctx, "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error on request timeout, got nil")
|
||||
}
|
||||
t.Logf("✅ Timeout → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. CheckCLIAuthEnabled: transient error then recovery → succeeds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !status.Success || !status.Result.CLIAuthEnabled {
|
||||
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ Transient error then success: attempts=%d, enabled=%v", calls.Load(), status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. CheckCLIAuthEnabled: normal responses (pass-through)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=false")
|
||||
}
|
||||
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OAuth callback: simulates the fail-closed logic at the /callback level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOAuthCallback_CLIAuthError_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error = fmt.Errorf("simulated network error")
|
||||
var authStatus *CLIAuthStatus
|
||||
_ = authStatus
|
||||
|
||||
// This is the exact expression used in oauth_provider.go callback:
|
||||
// cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil // false
|
||||
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when statusErr != nil")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=%v → cliAuthEnabled=%v → shows notEnabledHTML (fail-closed)", statusErr, cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
if !cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be true when API returns enabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=true → cliAuthEnabled=%v → shows successHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when server says disabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=false → cliAuthEnabled=%v → shows notEnabledHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Device Flow: loginOnce with broken cliAuthEnabled endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
|
||||
default:
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth check fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "检查 CLI 授权状态失败") && !strings.Contains(err.Error(), "Failed to check CLI auth status") {
|
||||
t.Fatalf("unexpected error message: %s", err)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth check error → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "张三"}},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth is disabled, got nil")
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth disabled by server → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected loginOnce to succeed, got error: %v", err)
|
||||
}
|
||||
if token.AccessToken != "test-access-token" {
|
||||
t.Fatalf("unexpected token: %s", token.AccessToken)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth enabled → login succeeded, token=%s", token.AccessToken)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. FetchClientIDFromMCP: /cli/clientId error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchClientIDFromMCP_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/clientId returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId 500 → fail-closed with retry: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `not json at all`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_BusinessError_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: false,
|
||||
ErrorCode: "forbidden",
|
||||
ErrorMsg: "access denied",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server returns success=false, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "access denied") {
|
||||
t.Fatalf("error should contain server error message, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId business error → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "recovered-client-id",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if id != "recovered-client-id" {
|
||||
t.Fatalf("expected client ID 'recovered-client-id', got %q", id)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId transient then success: attempts=%d, id=%s", calls.Load(), id)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != ClientIDPath {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "my-client-id-123",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "my-client-id-123" {
|
||||
t.Fatalf("expected 'my-client-id-123', got %q", id)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId normal success: id=%s", id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. GetSuperAdmins: /cli/superAdmin error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetSuperAdmins_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/superAdmin returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "a1", Name: "张三"}, {StaffID: "a2", Name: "李四"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !result.Success || len(result.Result) != 2 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin transient then success: attempts=%d, admins=%v", calls.Load(), result.Result)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "王五"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result.Result) != 1 || result.Result[0].Name != "王五" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin normal success: admins=%v", result.Result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. SendCliAuthApply: /cli/sendCliAuthApply error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendCliAuthApply_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/sendCliAuthApply returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 1 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failure, got error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply transient then success: attempts=%d", calls.Load())
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "adminStaffId=admin123") {
|
||||
t.Errorf("missing or wrong adminStaffId param: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "good-token", "admin123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply normal success: result=%+v", result)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_BusinessError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{
|
||||
Success: false,
|
||||
ErrorCode: "invalid_admin",
|
||||
ErrorMsg: "admin not found",
|
||||
Result: false,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected success=false for business error")
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply business error: errorCode=%s, errorMsg=%s", result.ErrorCode, result.ErrorMsg)
|
||||
}
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
@@ -167,15 +167,14 @@ 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 (fail-closed: block on error)
|
||||
// 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 {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
||||
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(), "")
|
||||
|
||||
@@ -140,27 +140,6 @@ func GetRevokeTokenURL() string {
|
||||
return "" // Direct mode doesn't have revoke endpoint
|
||||
}
|
||||
|
||||
// resolveCredentialSource determines the source of the current credentials.
|
||||
// Returns one of: "flag", "env", "app", "default".
|
||||
// This is used to track where credentials came from for token refresh.
|
||||
func resolveCredentialSource() string {
|
||||
clientMu.RLock()
|
||||
hasRuntimeOverride := runtimeClientID != "" || runtimeClientSecret != ""
|
||||
clientMu.RUnlock()
|
||||
|
||||
if hasRuntimeOverride {
|
||||
return "flag"
|
||||
}
|
||||
// Check if loaded from app config
|
||||
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
|
||||
return "app"
|
||||
}
|
||||
if os.Getenv("DWS_CLIENT_ID") != "" || os.Getenv("DWS_CLIENT_SECRET") != "" {
|
||||
return "env"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
|
||||
func SetClientID(id string) {
|
||||
clientMu.Lock()
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -25,8 +25,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
)
|
||||
|
||||
const identityFile = "identity.json"
|
||||
@@ -83,11 +82,8 @@ func (id *Identity) Headers() map[string]string {
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
}
|
||||
scenarioCode := "com.dingtalk.cli"
|
||||
if sc := edition.Get().ScenarioCode; sc != "" {
|
||||
scenarioCode = sc
|
||||
}
|
||||
h["x-dingtalk-scenario-code"] = scenarioCode
|
||||
// Constant headers for MCP gateway tracking
|
||||
h["x-dingtalk-scenario-code"] = "com.dingtalk.cli"
|
||||
h["x-dingtalk-source"] = "github"
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -105,45 +105,3 @@ func EnsureMigration(configDir string, logger *slog.Logger) {
|
||||
func IsMigrationDone() bool {
|
||||
return migrationDone
|
||||
}
|
||||
|
||||
// Client credential storage functions.
|
||||
// These store the clientSecret associated with a specific clientId,
|
||||
// allowing token refresh to work even if environment variables change.
|
||||
|
||||
const clientSecretPrefix = "client-secret:"
|
||||
|
||||
// SaveClientSecret stores the client secret for a specific client ID.
|
||||
// This is called during login to snapshot the credentials used.
|
||||
func SaveClientSecret(clientID, clientSecret string) error {
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil // Nothing to save
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
|
||||
return fmt.Errorf("save client secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadClientSecret retrieves the stored client secret for a specific client ID.
|
||||
// Returns empty string if not found.
|
||||
func LoadClientSecret(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
secret, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// DeleteClientSecret removes the stored client secret for a specific client ID.
|
||||
func DeleteClientSecret(clientID string) error {
|
||||
if clientID == "" {
|
||||
return nil
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
return keychain.Remove(keychain.Service, account)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
+188
-769
File diff suppressed because it is too large
Load Diff
@@ -131,11 +131,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
// Shared state for API handlers (protected by mutex)
|
||||
var (
|
||||
callbackToken *TokenData
|
||||
callbackProcessedCode string // The auth code that has been successfully processed
|
||||
callbackProcessed bool
|
||||
callbackAuthDisabled bool
|
||||
callbackApplySent bool // Whether apply request was sent
|
||||
callbackSelectedAdminId string // Selected admin ID for apply
|
||||
callbackCodeInProgress string // Code currently being processed (to prevent concurrent exchange)
|
||||
callbackTokenMu sync.Mutex
|
||||
)
|
||||
|
||||
@@ -147,48 +146,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
code = r.URL.Query().Get("code")
|
||||
}
|
||||
|
||||
// Check state and handle page refresh or concurrent requests
|
||||
// Check if this is a page refresh (no code) and callback was already processed
|
||||
callbackTokenMu.Lock()
|
||||
processedCode := callbackProcessedCode
|
||||
processedAuthDisabled := callbackAuthDisabled
|
||||
codeInProgress := callbackCodeInProgress
|
||||
hasToken := callbackToken != nil
|
||||
|
||||
// Case 1: This code was already successfully processed - show cached page
|
||||
if code != "" && code == processedCode {
|
||||
callbackTokenMu.Unlock()
|
||||
if code == "" && callbackProcessed {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if processedAuthDisabled {
|
||||
if callbackAuthDisabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: This code is being processed by another request - show wait page
|
||||
if code != "" && code == codeInProgress {
|
||||
callbackTokenMu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, `<html><head><meta http-equiv="refresh" content="1"></head><body><p>正在处理授权,请稍候...</p></body></html>`)
|
||||
return
|
||||
}
|
||||
|
||||
// Case 3: No code but we have a processed token - show cached page
|
||||
if code == "" && hasToken {
|
||||
callbackTokenMu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if processedAuthDisabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Case 4: New code - mark as in-progress and process
|
||||
if code != "" {
|
||||
callbackCodeInProgress = code
|
||||
// Reset state for new authorization (user switched org)
|
||||
if code != "" && callbackProcessed {
|
||||
callbackProcessed = false
|
||||
callbackApplySent = false
|
||||
callbackSelectedAdminId = ""
|
||||
}
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
@@ -202,13 +176,20 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
// Exchange code for token immediately in callback
|
||||
tokenData, exchangeErr := p.exchangeCode(ctx, code)
|
||||
if exchangeErr != nil {
|
||||
// Clear in-progress state on error
|
||||
// Check if we already have a processed state (authCode reused on refresh)
|
||||
callbackTokenMu.Lock()
|
||||
if callbackCodeInProgress == code {
|
||||
callbackCodeInProgress = ""
|
||||
if callbackProcessed {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if callbackAuthDisabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
}
|
||||
callbackTokenMu.Unlock()
|
||||
return
|
||||
}
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
@@ -221,31 +202,20 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as processed immediately after successful exchange
|
||||
callbackTokenMu.Lock()
|
||||
previouslyProcessed := callbackProcessedCode != ""
|
||||
callbackToken = tokenData
|
||||
callbackProcessedCode = code // Remember this code was successfully processed
|
||||
callbackCodeInProgress = "" // Clear in-progress state
|
||||
// Reset apply state for new authorization (user switched org)
|
||||
if previouslyProcessed {
|
||||
callbackApplySent = false
|
||||
callbackSelectedAdminId = ""
|
||||
}
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
|
||||
// Check CLI auth enabled status
|
||||
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
|
||||
|
||||
// Update CLI auth disabled state
|
||||
// Store token and state for API handlers and refresh handling
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
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 !cliAuthEnabled {
|
||||
if cliAuthDisabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
@@ -256,7 +226,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const secureDataFile = ".data"
|
||||
|
||||
@@ -25,12 +25,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -87,7 +88,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content")
|
||||
title := flagOrFallback(cmd, "title", "subject", "content")
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return apperrors.NewValidation("--title is required")
|
||||
}
|
||||
@@ -102,7 +103,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
"executorIds": executorIds,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -269,7 +270,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
inner["subject"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -443,6 +444,16 @@ func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────
|
||||
|
||||
// flagOrFallback returns the first non-empty value among the given flag names.
|
||||
func flagOrFallback(cmd *cobra.Command, names ...string) string {
|
||||
for _, name := range names {
|
||||
if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array.
|
||||
func parseExecutorIds(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
@@ -459,6 +470,25 @@ func parseExecutorIds(s string) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix
|
||||
// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix.
|
||||
func parseISOTimeToMillis(flagName, value string) (int64, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range formats {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName),
|
||||
)
|
||||
}
|
||||
|
||||
// ── list pagination helpers ────────────────────────────────
|
||||
|
||||
func normalizePage(raw string) string {
|
||||
|
||||
@@ -155,20 +155,5 @@
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
}
|
||||
|
||||
@@ -155,20 +155,5 @@
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"附件工作流": "附件工作流",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
package output
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
|
||||
|
||||
// SanitizeForTerminal strips ANSI escape sequences, control characters, and
|
||||
// dangerous Unicode from text before it is printed to a terminal.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// ParamNameHandler performs fuzzy correction on flag names that are
|
||||
@@ -102,7 +101,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
|
||||
ambiguous := false
|
||||
|
||||
for _, candidate := range candidates {
|
||||
dist := cmdutil.LevenshteinDist(bare, candidate)
|
||||
dist := levenshtein(bare, candidate)
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestMatch = candidate
|
||||
@@ -118,3 +117,48 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
|
||||
|
||||
return "--" + bestMatch + suffix, true
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between two strings using
|
||||
// the standard dynamic programming approach with O(min(m,n)) space.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
// Ensure a is the shorter string for O(min) space.
|
||||
if la > lb {
|
||||
a, b = b, a
|
||||
la, lb = lb, la
|
||||
}
|
||||
|
||||
prev := make([]int, la+1)
|
||||
curr := make([]int, la+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
|
||||
for j := 1; j <= lb; j++ {
|
||||
curr[0] = j
|
||||
for i := 1; i <= la; i++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[i] = min(
|
||||
prev[i]+1, // deletion
|
||||
curr[i-1]+1, // insertion
|
||||
prev[i-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[la]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
@@ -41,11 +40,12 @@ func TestLevenshtein(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
|
||||
got := cmdutil.LevenshteinDist(tt.a, tt.b)
|
||||
got := levenshtein(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
gotRev := cmdutil.LevenshteinDist(tt.b, tt.a)
|
||||
// Verify symmetry.
|
||||
gotRev := levenshtein(tt.b, tt.a)
|
||||
if gotRev != got {
|
||||
t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
)
|
||||
|
||||
// DataFileName is the encrypted token data file name.
|
||||
|
||||
@@ -30,11 +30,11 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadConfig holds download configuration.
|
||||
type DownloadConfig struct {
|
||||
MaxRetries int
|
||||
Timeout time.Duration
|
||||
ProgressCallback func(downloaded, total int64)
|
||||
ProgressInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultDownloadConfig returns sensible download defaults.
|
||||
func DefaultDownloadConfig() *DownloadConfig {
|
||||
return &DownloadConfig{
|
||||
MaxRetries: 3,
|
||||
Timeout: 10 * time.Minute,
|
||||
ProgressInterval: 200 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// Download fetches url to destPath with default config.
|
||||
func Download(url, destPath string) (int64, error) {
|
||||
return DownloadWithConfig(context.Background(), url, destPath, DefaultDownloadConfig())
|
||||
}
|
||||
|
||||
// DownloadWithProgress downloads a file and reports progress.
|
||||
func DownloadWithProgress(ctx context.Context, url, destPath string, showProgress func(percent float64, downloaded, total int64)) (int64, error) {
|
||||
cfg := DefaultDownloadConfig()
|
||||
cfg.ProgressCallback = func(downloaded, total int64) {
|
||||
if showProgress != nil && total > 0 {
|
||||
percent := float64(downloaded) / float64(total) * 100
|
||||
showProgress(percent, downloaded, total)
|
||||
}
|
||||
}
|
||||
return DownloadWithConfig(ctx, url, destPath, cfg)
|
||||
}
|
||||
|
||||
// DownloadWithConfig fetches a file with custom configuration and retries.
|
||||
func DownloadWithConfig(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return 0, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries < 1 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Second * time.Duration(1<<uint(attempt-1))
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
n, err := doDownload(ctx, url, destPath, cfg)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
if !isRetriable(err) {
|
||||
return 0, err
|
||||
}
|
||||
os.Remove(destPath)
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("下载失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func doDownload(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{}
|
||||
if cfg.Timeout > 0 {
|
||||
client.Timeout = cfg.Timeout
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("下载失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("下载失败 (HTTP %d): %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
var writer io.Writer = out
|
||||
if cfg.ProgressCallback != nil {
|
||||
pw := &progressWriter{
|
||||
writer: out,
|
||||
total: resp.ContentLength,
|
||||
callback: cfg.ProgressCallback,
|
||||
interval: cfg.ProgressInterval,
|
||||
lastCallback: time.Now(),
|
||||
}
|
||||
defer pw.finalProgress()
|
||||
writer = pw
|
||||
}
|
||||
|
||||
n, err := io.Copy(writer, resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// progressWriter wraps an io.Writer and reports download progress.
|
||||
type progressWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
downloaded int64
|
||||
callback func(downloaded, total int64)
|
||||
interval time.Duration
|
||||
lastCallback time.Time
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.downloaded += int64(n)
|
||||
|
||||
now := time.Now()
|
||||
if pw.callback != nil && now.Sub(pw.lastCallback) >= pw.interval {
|
||||
pw.callback(pw.downloaded, pw.total)
|
||||
pw.lastCallback = now
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (pw *progressWriter) finalProgress() {
|
||||
if pw.callback != nil {
|
||||
pw.callback(pw.downloaded, pw.total)
|
||||
}
|
||||
}
|
||||
|
||||
func isRetriable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if os.IsTimeout(err) {
|
||||
return true
|
||||
}
|
||||
errStr := err.Error()
|
||||
for _, pattern := range []string{"connection reset", "connection refused", "timeout", "temporary failure", "eof"} {
|
||||
if strings.Contains(strings.ToLower(errStr), pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownload_Success(t *testing.T) {
|
||||
body := "hello world binary content"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "downloaded")
|
||||
n, err := Download(server.URL+"/file.tar.gz", dest)
|
||||
if err != nil {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
if n != int64(len(body)) {
|
||||
t.Errorf("Download() = %d bytes, want %d", n, len(body))
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dest)
|
||||
if string(got) != body {
|
||||
t.Errorf("file content = %q, want %q", string(got), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_HTTP404(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "notfound")
|
||||
_, err := Download(server.URL+"/missing", dest)
|
||||
if err == nil {
|
||||
t.Fatal("Download() expected error for 404")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_HTTP500_RetriesAndFails(t *testing.T) {
|
||||
var attempts int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "err")
|
||||
cfg := &DownloadConfig{
|
||||
MaxRetries: 2,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
_, err := DownloadWithConfig(context.Background(), server.URL+"/fail", dest, cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after retries")
|
||||
}
|
||||
|
||||
// 500 is not retriable (no "connection reset" pattern), so only 1 attempt
|
||||
got := atomic.LoadInt32(&attempts)
|
||||
if got != 1 {
|
||||
t.Errorf("attempts = %d, want 1 (500 is not retriable)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.Write([]byte("late"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "cancelled")
|
||||
_, err := DownloadWithProgress(ctx, server.URL+"/slow", dest, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWithProgress_Callback(t *testing.T) {
|
||||
body := make([]byte, 1024)
|
||||
for i := range body {
|
||||
body[i] = 'A'
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
w.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var called int32
|
||||
dest := filepath.Join(t.TempDir(), "progress")
|
||||
n, err := DownloadWithProgress(context.Background(), server.URL+"/file", dest,
|
||||
func(percent float64, downloaded, total int64) {
|
||||
atomic.AddInt32(&called, 1)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithProgress() error = %v", err)
|
||||
}
|
||||
if n != int64(len(body)) {
|
||||
t.Errorf("bytes = %d, want %d", n, len(body))
|
||||
}
|
||||
if atomic.LoadInt32(&called) == 0 {
|
||||
t.Error("progress callback was never called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_CreatesParentDirectories(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "a", "b", "c", "file")
|
||||
_, err := Download(server.URL+"/file", dest)
|
||||
if err != nil {
|
||||
t.Fatalf("Download() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDownloadConfig(t *testing.T) {
|
||||
cfg := DefaultDownloadConfig()
|
||||
if cfg.MaxRetries != 3 {
|
||||
t.Errorf("MaxRetries = %d, want 3", cfg.MaxRetries)
|
||||
}
|
||||
if cfg.Timeout != 10*time.Minute {
|
||||
t.Errorf("Timeout = %v, want 10m", cfg.Timeout)
|
||||
}
|
||||
if cfg.ProgressInterval != 200*time.Millisecond {
|
||||
t.Errorf("ProgressInterval = %v, want 200ms", cfg.ProgressInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetriable(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{fmt.Errorf("connection reset by peer"), true},
|
||||
{fmt.Errorf("Connection Refused"), true},
|
||||
{fmt.Errorf("request timeout exceeded"), true},
|
||||
{fmt.Errorf("temporary failure in name resolution"), true},
|
||||
{fmt.Errorf("unexpected EOF"), true},
|
||||
{fmt.Errorf("permission denied"), false},
|
||||
{fmt.Errorf("file not found"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isRetriable(tt.err)
|
||||
if got != tt.want {
|
||||
t.Errorf("isRetriable(%q) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package upgrade provides self-update functionality for the DWS CLI
|
||||
// using GitHub Releases as the data source.
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gitHubAPIBase = "https://api.github.com"
|
||||
defaultOwner = "DingTalk-Real-AI"
|
||||
defaultRepo = "dingtalk-workspace-cli"
|
||||
httpTimeout = 30 * time.Second
|
||||
userAgent = "DWS-CLI-Upgrade/1.0"
|
||||
skillsZipName = "dws-skills.zip"
|
||||
checksumsName = "checksums.txt"
|
||||
)
|
||||
|
||||
// GitHubRelease represents a single release from the GitHub Releases API.
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []GitHubAsset `json:"assets"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// GitHubAsset represents a release asset (downloadable file).
|
||||
type GitHubAsset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// ReleaseInfo is the simplified view of a release used throughout the upgrade flow.
|
||||
type ReleaseInfo struct {
|
||||
Version string
|
||||
Date string
|
||||
Changelog string
|
||||
Prerelease bool
|
||||
HTMLURL string
|
||||
Assets []GitHubAsset
|
||||
}
|
||||
|
||||
// VersionEntry represents a single version in the version list.
|
||||
type VersionEntry struct {
|
||||
Version string
|
||||
Date string
|
||||
Changelog string
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
// Client communicates with the GitHub Releases API.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
owner string
|
||||
repo string
|
||||
baseURL string // overridable for testing or mirrors
|
||||
}
|
||||
|
||||
// NewClient creates a GitHub release client with default settings.
|
||||
func NewClient() *Client {
|
||||
baseURL := gitHubAPIBase
|
||||
if env := os.Getenv("DWS_UPGRADE_URL"); env != "" {
|
||||
baseURL = strings.TrimRight(env, "/")
|
||||
}
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithBaseURL creates a client with a custom base URL (for testing).
|
||||
func NewClientWithBaseURL(baseURL string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLatestRelease returns the latest non-draft release.
|
||||
func (c *Client) FetchLatestRelease() (*ReleaseInfo, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL, c.owner, c.repo)
|
||||
|
||||
var gh GitHubRelease
|
||||
if err := c.getJSON(url, &gh); err != nil {
|
||||
return nil, fmt.Errorf("获取最新版本失败: %w", err)
|
||||
}
|
||||
|
||||
return ghReleaseToInfo(&gh), nil
|
||||
}
|
||||
|
||||
// FetchReleaseByTag returns the release for a specific tag (e.g. "v1.0.5").
|
||||
func (c *Client) FetchReleaseByTag(tag string) (*ReleaseInfo, error) {
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
tag = "v" + tag
|
||||
}
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", c.baseURL, c.owner, c.repo, tag)
|
||||
|
||||
var gh GitHubRelease
|
||||
if err := c.getJSON(url, &gh); err != nil {
|
||||
return nil, fmt.Errorf("获取版本 %s 失败: %w", tag, err)
|
||||
}
|
||||
|
||||
return ghReleaseToInfo(&gh), nil
|
||||
}
|
||||
|
||||
// FetchAllReleases returns all non-draft releases, newest first.
|
||||
func (c *Client) FetchAllReleases() ([]VersionEntry, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", c.baseURL, c.owner, c.repo)
|
||||
|
||||
var ghReleases []GitHubRelease
|
||||
if err := c.getJSON(url, &ghReleases); err != nil {
|
||||
return nil, fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
var versions []VersionEntry
|
||||
for _, gh := range ghReleases {
|
||||
if gh.Draft {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, VersionEntry{
|
||||
Version: stripV(gh.TagName),
|
||||
Date: formatDate(gh.PublishedAt),
|
||||
Changelog: gh.Body,
|
||||
Prerelease: gh.Prerelease,
|
||||
})
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// FindBinaryAsset locates the platform-specific binary archive from the release assets.
|
||||
// Pattern: dws-{os}-{arch}.tar.gz (or .zip for windows).
|
||||
func FindBinaryAsset(assets []GitHubAsset) (*GitHubAsset, error) {
|
||||
return FindBinaryAssetFor(assets, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// FindBinaryAssetFor locates the binary archive for a specific platform.
|
||||
func FindBinaryAssetFor(assets []GitHubAsset, goos, goarch string) (*GitHubAsset, error) {
|
||||
ext := ".tar.gz"
|
||||
if goos == "windows" {
|
||||
ext = ".zip"
|
||||
}
|
||||
target := fmt.Sprintf("dws-%s-%s%s", goos, goarch, ext)
|
||||
|
||||
for i := range assets {
|
||||
if assets[i].Name == target {
|
||||
return &assets[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("当前平台 %s/%s 没有可用的预编译二进制 (需要 %s)", goos, goarch, target)
|
||||
}
|
||||
|
||||
// FindSkillsAsset locates the dws-skills.zip asset.
|
||||
func FindSkillsAsset(assets []GitHubAsset) *GitHubAsset {
|
||||
for i := range assets {
|
||||
if assets[i].Name == skillsZipName {
|
||||
return &assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindChecksumsAsset locates the checksums.txt asset.
|
||||
func FindChecksumsAsset(assets []GitHubAsset) *GitHubAsset {
|
||||
for i := range assets {
|
||||
if assets[i].Name == checksumsName {
|
||||
return &assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractDigestSHA256 extracts the hex hash from a GitHub asset digest field.
|
||||
// GitHub format: "sha256:abcdef1234..."
|
||||
func ExtractDigestSHA256(digest string) string {
|
||||
if strings.HasPrefix(digest, "sha256:") {
|
||||
return digest[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getJSON performs a GET request and decodes the JSON response.
|
||||
func (c *Client) getJSON(url string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
if token := githubToken(); token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法连接到 GitHub: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining == "0" {
|
||||
return fmt.Errorf("GitHub API 请求频率超限。设置 GITHUB_TOKEN 或 GH_TOKEN 环境变量可提升限额")
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("未找到 (404)")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GitHub API 返回 HTTP %d: %s", resp.StatusCode, truncateBytes(body, 200))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
|
||||
return fmt.Errorf("解析 GitHub 响应失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// githubToken returns a GitHub token from environment if available.
|
||||
func githubToken() string {
|
||||
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := os.Getenv("GH_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ghReleaseToInfo(gh *GitHubRelease) *ReleaseInfo {
|
||||
return &ReleaseInfo{
|
||||
Version: stripV(gh.TagName),
|
||||
Date: formatDate(gh.PublishedAt),
|
||||
Changelog: gh.Body,
|
||||
Prerelease: gh.Prerelease,
|
||||
HTMLURL: gh.HTMLURL,
|
||||
Assets: gh.Assets,
|
||||
}
|
||||
}
|
||||
|
||||
func stripV(tag string) string {
|
||||
return strings.TrimPrefix(tag, "v")
|
||||
}
|
||||
|
||||
func formatDate(published string) string {
|
||||
t, err := time.Parse(time.RFC3339, published)
|
||||
if err != nil {
|
||||
return published
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func truncateBody(body string, maxLen int) string {
|
||||
body = strings.TrimSpace(body)
|
||||
lines := strings.SplitN(body, "\n", 2)
|
||||
first := lines[0]
|
||||
if len(first) > maxLen {
|
||||
return first[:maxLen-3] + "..."
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func truncateBytes(b []byte, max int) string {
|
||||
if len(b) > max {
|
||||
return string(b[:max]) + "..."
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchLatestRelease(t *testing.T) {
|
||||
release := GitHubRelease{
|
||||
TagName: "v1.0.6",
|
||||
Name: "v1.0.6",
|
||||
Body: "Bug fixes and improvements",
|
||||
Prerelease: false,
|
||||
Draft: false,
|
||||
PublishedAt: "2026-04-01T09:29:05Z",
|
||||
Assets: []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
|
||||
{Name: "dws-skills.zip", Size: 50000, BrowserDownloadURL: "https://example.com/dws-skills.zip"},
|
||||
{Name: "checksums.txt", Size: 534, BrowserDownloadURL: "https://example.com/checksums.txt"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/latest" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(release)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
info, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLatestRelease() error = %v", err)
|
||||
}
|
||||
|
||||
if info.Version != "1.0.6" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "1.0.6")
|
||||
}
|
||||
if info.Date != "2026-04-01" {
|
||||
t.Errorf("Date = %q, want %q", info.Date, "2026-04-01")
|
||||
}
|
||||
if len(info.Assets) != 3 {
|
||||
t.Errorf("len(Assets) = %d, want 3", len(info.Assets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseByTag(t *testing.T) {
|
||||
release := GitHubRelease{
|
||||
TagName: "v1.0.5",
|
||||
PublishedAt: "2026-03-25T10:00:00Z",
|
||||
Assets: []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3800000, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tags/v1.0.5"
|
||||
if r.URL.Path != expected {
|
||||
t.Errorf("path = %q, want %q", r.URL.Path, expected)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(release)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
|
||||
// Without "v" prefix
|
||||
info, err := client.FetchReleaseByTag("1.0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReleaseByTag() error = %v", err)
|
||||
}
|
||||
if info.Version != "1.0.5" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllReleases(t *testing.T) {
|
||||
releases := []GitHubRelease{
|
||||
{TagName: "v1.0.6", PublishedAt: "2026-04-01T09:00:00Z", Draft: false, Prerelease: false},
|
||||
{TagName: "v1.0.6-beta", PublishedAt: "2026-03-28T09:00:00Z", Draft: false, Prerelease: true},
|
||||
{TagName: "v1.0.5", PublishedAt: "2026-03-25T09:00:00Z", Draft: false, Prerelease: false},
|
||||
{TagName: "v1.0.4-draft", PublishedAt: "2026-03-20T09:00:00Z", Draft: true, Prerelease: false},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(releases)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
versions, err := client.FetchAllReleases()
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAllReleases() error = %v", err)
|
||||
}
|
||||
|
||||
// Draft should be filtered out
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("len(versions) = %d, want 3", len(versions))
|
||||
}
|
||||
if versions[0].Version != "1.0.6" {
|
||||
t.Errorf("versions[0].Version = %q, want %q", versions[0].Version, "1.0.6")
|
||||
}
|
||||
if versions[1].Prerelease != true {
|
||||
t.Errorf("versions[1].Prerelease = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "checksums.txt", Size: 534},
|
||||
{Name: "dws-darwin-amd64.tar.gz", Size: 4276643},
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308},
|
||||
{Name: "dws-linux-amd64.tar.gz", Size: 4394696},
|
||||
{Name: "dws-windows-amd64.zip", Size: 4500000},
|
||||
{Name: "dws-skills.zip", Size: 50000},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
goos, goarch string
|
||||
wantName string
|
||||
wantErr bool
|
||||
}{
|
||||
{"darwin", "arm64", "dws-darwin-arm64.tar.gz", false},
|
||||
{"darwin", "amd64", "dws-darwin-amd64.tar.gz", false},
|
||||
{"linux", "amd64", "dws-linux-amd64.tar.gz", false},
|
||||
{"windows", "amd64", "dws-windows-amd64.zip", false},
|
||||
{"freebsd", "amd64", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
asset, err := FindBinaryAssetFor(assets, tt.goos, tt.goarch)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s) expected error", tt.goos, tt.goarch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s) error = %v", tt.goos, tt.goarch, err)
|
||||
continue
|
||||
}
|
||||
if asset.Name != tt.wantName {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s).Name = %q, want %q", tt.goos, tt.goarch, asset.Name, tt.wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSkillsAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz"},
|
||||
{Name: "dws-skills.zip", Size: 50000},
|
||||
{Name: "checksums.txt"},
|
||||
}
|
||||
|
||||
skill := FindSkillsAsset(assets)
|
||||
if skill == nil {
|
||||
t.Fatal("FindSkillsAsset() returned nil")
|
||||
}
|
||||
if skill.Name != "dws-skills.zip" {
|
||||
t.Errorf("Name = %q, want %q", skill.Name, "dws-skills.zip")
|
||||
}
|
||||
|
||||
// No skills asset
|
||||
noSkills := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
|
||||
if got := FindSkillsAsset(noSkills); got != nil {
|
||||
t.Errorf("FindSkillsAsset() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChecksumsAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz"},
|
||||
{Name: "checksums.txt", Size: 534},
|
||||
}
|
||||
|
||||
checksums := FindChecksumsAsset(assets)
|
||||
if checksums == nil {
|
||||
t.Fatal("FindChecksumsAsset() returned nil")
|
||||
}
|
||||
if checksums.Name != "checksums.txt" {
|
||||
t.Errorf("Name = %q, want %q", checksums.Name, "checksums.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDigestSHA256(t *testing.T) {
|
||||
tests := []struct {
|
||||
digest string
|
||||
want string
|
||||
}{
|
||||
{"sha256:abcdef1234567890", "abcdef1234567890"},
|
||||
{"sha256:", ""},
|
||||
{"md5:abcdef", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ExtractDigestSHA256(tt.digest)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtractDigestSHA256(%q) = %q, want %q", tt.digest, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
_, err := client.FetchLatestRelease()
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
if got := err.Error(); !contains(got, "频率超限") {
|
||||
t.Errorf("error = %q, want to contain rate limit message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLatestRelease_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
_, err := client.FetchLatestRelease()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
if !contains(err.Error(), "404") {
|
||||
t.Errorf("error = %q, want to contain '404'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseByTag_InvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("{invalid json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
_, err := client.FetchReleaseByTag("v1.0.0")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLatestRelease_FieldMapping(t *testing.T) {
|
||||
release := GitHubRelease{
|
||||
TagName: "v2.1.0",
|
||||
Body: "## Changelog\n* abc1234 - add new feature\n* def5678 Merge branch 'main'",
|
||||
Prerelease: true,
|
||||
PublishedAt: "2026-06-15T14:30:00Z",
|
||||
HTMLURL: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tag/v2.1.0",
|
||||
Assets: []GitHubAsset{},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(release)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
info, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
|
||||
if info.Version != "2.1.0" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "2.1.0")
|
||||
}
|
||||
if info.Date != "2026-06-15" {
|
||||
t.Errorf("Date = %q, want %q", info.Date, "2026-06-15")
|
||||
}
|
||||
if !info.Prerelease {
|
||||
t.Error("Prerelease = false, want true")
|
||||
}
|
||||
if info.HTMLURL != release.HTMLURL {
|
||||
t.Errorf("HTMLURL = %q, want %q", info.HTMLURL, release.HTMLURL)
|
||||
}
|
||||
if info.Changelog == "" {
|
||||
t.Error("Changelog should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllReleases_EmptyList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode([]GitHubRelease{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
versions, err := client.FetchAllReleases()
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(versions) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(versions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllReleases_AllDrafts(t *testing.T) {
|
||||
releases := []GitHubRelease{
|
||||
{TagName: "v1.0.0", Draft: true},
|
||||
{TagName: "v0.9.0", Draft: true},
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(releases)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
versions, err := client.FetchAllReleases()
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(versions) != 0 {
|
||||
t.Errorf("all-drafts: len = %d, want 0", len(versions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseByTag_WithVPrefix(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tags/v1.0.5"
|
||||
if r.URL.Path != expected {
|
||||
t.Errorf("path = %q, want %q", r.URL.Path, expected)
|
||||
}
|
||||
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.5", PublishedAt: "2026-01-01T00:00:00Z"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
info, err := client.FetchReleaseByTag("v1.0.5") // already has "v"
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if info.Version != "1.0.5" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChecksumsAsset_NotFound(t *testing.T) {
|
||||
assets := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
|
||||
if got := FindChecksumsAsset(assets); got != nil {
|
||||
t.Errorf("FindChecksumsAsset() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryAssetFor_EmptyAssets(t *testing.T) {
|
||||
_, err := FindBinaryAssetFor(nil, "darwin", "arm64")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty assets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripV(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"v1.0.0", "1.0.0"},
|
||||
{"1.0.0", "1.0.0"},
|
||||
{"v", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripV(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripV(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"2026-04-01T09:29:05Z", "2026-04-01"},
|
||||
{"invalid-date", "invalid-date"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := formatDate(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatDate(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short", 100, "short"},
|
||||
{"line1\nline2", 100, "line1"},
|
||||
{"a very long string here", 10, "a very ..."},
|
||||
{"", 10, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := truncateBody(tt.body, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateBody(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubToken(t *testing.T) {
|
||||
// Clean env first
|
||||
t.Setenv("GITHUB_TOKEN", "")
|
||||
t.Setenv("GH_TOKEN", "")
|
||||
if got := githubToken(); got != "" {
|
||||
t.Errorf("expected empty token, got %q", got)
|
||||
}
|
||||
|
||||
t.Setenv("GITHUB_TOKEN", "gh-tok-123")
|
||||
if got := githubToken(); got != "gh-tok-123" {
|
||||
t.Errorf("expected gh-tok-123, got %q", got)
|
||||
}
|
||||
|
||||
t.Setenv("GITHUB_TOKEN", "")
|
||||
t.Setenv("GH_TOKEN", "gh-alt-456")
|
||||
if got := githubToken(); got != "gh-alt-456" {
|
||||
t.Errorf("expected gh-alt-456, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJSON_SetsHeaders(t *testing.T) {
|
||||
t.Setenv("GITHUB_TOKEN", "test-token-abc")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ua := r.Header.Get("User-Agent"); ua != userAgent {
|
||||
t.Errorf("User-Agent = %q, want %q", ua, userAgent)
|
||||
}
|
||||
if accept := r.Header.Get("Accept"); accept != "application/vnd.github+json" {
|
||||
t.Errorf("Accept = %q, want application/vnd.github+json", accept)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "token test-token-abc" {
|
||||
t.Errorf("Authorization = %q, want %q", auth, "token test-token-abc")
|
||||
}
|
||||
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.0", PublishedAt: "2026-01-01T00:00:00Z"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
_, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Permission constants following Unix best practices.
|
||||
const (
|
||||
dirPermSecure os.FileMode = 0o700
|
||||
dirPermShared os.FileMode = 0o755
|
||||
filePermBinary os.FileMode = 0o755
|
||||
filePermConfig os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
|
||||
// Kept in sync with build/npm/install.js AGENT_DIRS.
|
||||
// The first entry (.agents/skills) is always updated; subsequent entries are
|
||||
// only updated when their parent directory already exists.
|
||||
var knownSkillDirs = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
".cline/skills",
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
|
||||
var skillDirBlacklist = []string{
|
||||
".real",
|
||||
}
|
||||
|
||||
// SkillDirStatus describes the installation outcome for a single skill directory.
|
||||
type SkillDirStatus int
|
||||
|
||||
const (
|
||||
SkillDirOK SkillDirStatus = iota // successfully installed
|
||||
SkillDirSkipped // agent not detected, directory skipped
|
||||
SkillDirBlacklisted // blacklisted, never touched
|
||||
SkillDirFailed // installation attempted but failed
|
||||
)
|
||||
|
||||
// SkillDirResult holds the per-directory install result.
|
||||
type SkillDirResult struct {
|
||||
Dir string // destination directory (e.g. ~/.claude/skills/dws)
|
||||
Status SkillDirStatus // outcome
|
||||
Err error // non-nil when Status == SkillDirFailed
|
||||
}
|
||||
|
||||
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
|
||||
type SkillUpgradeResult struct {
|
||||
Results []SkillDirResult
|
||||
}
|
||||
|
||||
// Succeeded returns directories that were successfully updated.
|
||||
func (r *SkillUpgradeResult) Succeeded() []SkillDirResult {
|
||||
var out []SkillDirResult
|
||||
for _, d := range r.Results {
|
||||
if d.Status == SkillDirOK {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Failed returns directories where installation was attempted but failed.
|
||||
func (r *SkillUpgradeResult) Failed() []SkillDirResult {
|
||||
var out []SkillDirResult
|
||||
for _, d := range r.Results {
|
||||
if d.Status == SkillDirFailed {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpgradeSkillLocations installs skills from extractedDir into all locations
|
||||
// where they are currently installed or expected.
|
||||
//
|
||||
// Strategy (matches npm install.js installSkillsToHomes):
|
||||
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
|
||||
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
|
||||
// directory exists (e.g. ~/.claude/ exists => user has Claude)
|
||||
// - ~/.real/ and other blacklisted paths are NEVER touched
|
||||
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
|
||||
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SkillUpgradeResult{}
|
||||
|
||||
for i, agentDir := range knownSkillDirs {
|
||||
destDir := filepath.Join(homeDir, agentDir, "dws")
|
||||
|
||||
if isBlacklisted(agentDir) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
|
||||
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
os.RemoveAll(destDir)
|
||||
if err := copyDir(extractedDir, destDir); err != nil {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
|
||||
continue
|
||||
}
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
|
||||
}
|
||||
|
||||
// Fallback: if nothing succeeded, force the primary location
|
||||
if len(result.Succeeded()) == 0 {
|
||||
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
os.MkdirAll(filepath.Dir(dest), dirPermShared)
|
||||
if err := copyDir(extractedDir, dest); err != nil {
|
||||
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
|
||||
}
|
||||
// Replace the earlier failed entry for this dir (if any) or append a new one
|
||||
replaced := false
|
||||
for idx, r := range result.Results {
|
||||
if r.Dir == dest {
|
||||
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
|
||||
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
|
||||
func LocateSkillMD(extractDir string) string {
|
||||
// Check nested: {extractDir}/dws/SKILL.md
|
||||
nested := filepath.Join(extractDir, "dws", "SKILL.md")
|
||||
if _, err := os.Stat(nested); err == nil {
|
||||
return filepath.Join(extractDir, "dws")
|
||||
}
|
||||
|
||||
// Check flat: {extractDir}/SKILL.md
|
||||
flat := filepath.Join(extractDir, "SKILL.md")
|
||||
if _, err := os.Stat(flat); err == nil {
|
||||
return extractDir
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// EnsureUpgradeDirectories creates the directories needed for upgrade operations.
|
||||
func EnsureUpgradeDirectories() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirs := []struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
}{
|
||||
{filepath.Join(homeDir, ".dws"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "data"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "data", "backups"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "cache"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "cache", "downloads"), dirPermSecure},
|
||||
}
|
||||
|
||||
for _, d := range dirs {
|
||||
if err := ensureDir(d.path, d.perm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCacheDir returns the path for temporary downloads during upgrade.
|
||||
func DownloadCacheDir() string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".dws", "cache", "downloads")
|
||||
}
|
||||
|
||||
// CurrentBinaryPath returns the resolved path of the currently running binary.
|
||||
func CurrentBinaryPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(exe)
|
||||
}
|
||||
|
||||
// BinaryName returns the platform-specific binary name.
|
||||
func BinaryName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "dws.exe"
|
||||
}
|
||||
return "dws"
|
||||
}
|
||||
|
||||
func isBlacklisted(agentDir string) bool {
|
||||
for _, bl := range skillDirBlacklist {
|
||||
// agentDir is like ".real/skills" — check if it starts with a blacklisted prefix
|
||||
if len(agentDir) >= len(bl) && agentDir[:len(bl)] == bl {
|
||||
next := len(bl)
|
||||
if next == len(agentDir) || agentDir[next] == '/' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureDir(path string, perm os.FileMode) error {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(path, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != perm {
|
||||
if info.Mode().Perm()&^perm != 0 {
|
||||
return os.Chmod(path, perm)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsBlacklisted(t *testing.T) {
|
||||
tests := []struct {
|
||||
dir string
|
||||
want bool
|
||||
}{
|
||||
{".real/skills", true},
|
||||
{".real", true},
|
||||
{".agents/skills", false},
|
||||
{".claude/skills", false},
|
||||
{".cursor/skills", false},
|
||||
{".realtime/skills", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isBlacklisted(tt.dir)
|
||||
if got != tt.want {
|
||||
t.Errorf("isBlacklisted(%q) = %v, want %v", tt.dir, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateSkillMD(t *testing.T) {
|
||||
// Flat layout
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# skill"), 0644)
|
||||
|
||||
result := LocateSkillMD(dir)
|
||||
if result != dir {
|
||||
t.Errorf("flat layout: LocateSkillMD() = %q, want %q", result, dir)
|
||||
}
|
||||
|
||||
// Nested layout
|
||||
dir2 := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir2, "dws"), 0755)
|
||||
os.WriteFile(filepath.Join(dir2, "dws", "SKILL.md"), []byte("# skill"), 0644)
|
||||
|
||||
result2 := LocateSkillMD(dir2)
|
||||
want2 := filepath.Join(dir2, "dws")
|
||||
if result2 != want2 {
|
||||
t.Errorf("nested layout: LocateSkillMD() = %q, want %q", result2, want2)
|
||||
}
|
||||
|
||||
// No SKILL.md
|
||||
dir3 := t.TempDir()
|
||||
result3 := LocateSkillMD(dir3)
|
||||
if result3 != "" {
|
||||
t.Errorf("empty dir: LocateSkillMD() = %q, want empty", result3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocations(t *testing.T) {
|
||||
skillSrc := t.TempDir()
|
||||
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
|
||||
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
|
||||
os.WriteFile(filepath.Join(skillSrc, "references", "guide.md"), []byte("# guide"), 0644)
|
||||
|
||||
result, err := UpgradeSkillLocations(skillSrc)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
|
||||
succeeded := result.Succeeded()
|
||||
if len(succeeded) == 0 {
|
||||
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
|
||||
found := false
|
||||
for _, d := range succeeded {
|
||||
if d.Dir == primaryDest {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
dirs := make([]string, len(succeeded))
|
||||
for i, d := range succeeded {
|
||||
dirs[i] = d.Dir
|
||||
}
|
||||
t.Errorf("primary location %s not in succeeded list: %v", primaryDest, dirs)
|
||||
}
|
||||
|
||||
skillMDPath := filepath.Join(primaryDest, "SKILL.md")
|
||||
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
|
||||
t.Errorf("SKILL.md not found at %s", skillMDPath)
|
||||
}
|
||||
|
||||
guidePath := filepath.Join(primaryDest, "references", "guide.md")
|
||||
if _, err := os.Stat(guidePath); os.IsNotExist(err) {
|
||||
t.Errorf("references/guide.md not found at %s", guidePath)
|
||||
}
|
||||
|
||||
// Verify that failed list is empty for a normal install
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Errorf("expected 0 failures, got %d", len(result.Failed()))
|
||||
}
|
||||
|
||||
// Verify total results contain at least one entry for each known (non-blacklisted) dir
|
||||
if len(result.Results) == 0 {
|
||||
t.Error("expected non-empty Results")
|
||||
}
|
||||
|
||||
os.RemoveAll(primaryDest)
|
||||
}
|
||||
|
||||
func TestBlacklistPreventsRealDir(t *testing.T) {
|
||||
for _, dir := range knownSkillDirs {
|
||||
if isBlacklisted(dir) {
|
||||
t.Errorf("knownSkillDirs contains blacklisted entry: %q", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillUpgradeResult_SucceededAndFailed(t *testing.T) {
|
||||
result := &SkillUpgradeResult{
|
||||
Results: []SkillDirResult{
|
||||
{Dir: "/a", Status: SkillDirOK},
|
||||
{Dir: "/b", Status: SkillDirFailed, Err: os.ErrPermission},
|
||||
{Dir: "/c", Status: SkillDirSkipped},
|
||||
{Dir: "/d", Status: SkillDirBlacklisted},
|
||||
{Dir: "/e", Status: SkillDirOK},
|
||||
},
|
||||
}
|
||||
|
||||
succeeded := result.Succeeded()
|
||||
if len(succeeded) != 2 {
|
||||
t.Errorf("Succeeded() len = %d, want 2", len(succeeded))
|
||||
}
|
||||
if succeeded[0].Dir != "/a" || succeeded[1].Dir != "/e" {
|
||||
t.Errorf("Succeeded() dirs = %v, %v", succeeded[0].Dir, succeeded[1].Dir)
|
||||
}
|
||||
|
||||
failed := result.Failed()
|
||||
if len(failed) != 1 {
|
||||
t.Errorf("Failed() len = %d, want 1", len(failed))
|
||||
}
|
||||
if failed[0].Dir != "/b" {
|
||||
t.Errorf("Failed()[0].Dir = %q, want /b", failed[0].Dir)
|
||||
}
|
||||
if failed[0].Err != os.ErrPermission {
|
||||
t.Errorf("Failed()[0].Err = %v, want ErrPermission", failed[0].Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillUpgradeResult_EmptyResults(t *testing.T) {
|
||||
result := &SkillUpgradeResult{}
|
||||
if len(result.Succeeded()) != 0 {
|
||||
t.Error("empty Succeeded should be nil/empty")
|
||||
}
|
||||
if len(result.Failed()) != 0 {
|
||||
t.Error("empty Failed should be nil/empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDirStatusConstants(t *testing.T) {
|
||||
if SkillDirOK != 0 {
|
||||
t.Errorf("SkillDirOK = %d, want 0", SkillDirOK)
|
||||
}
|
||||
if SkillDirSkipped != 1 {
|
||||
t.Errorf("SkillDirSkipped = %d, want 1", SkillDirSkipped)
|
||||
}
|
||||
if SkillDirBlacklisted != 2 {
|
||||
t.Errorf("SkillDirBlacklisted = %d, want 2", SkillDirBlacklisted)
|
||||
}
|
||||
if SkillDirFailed != 3 {
|
||||
t.Errorf("SkillDirFailed = %d, want 3", SkillDirFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUpgradeDirectories(t *testing.T) {
|
||||
// This test verifies the structure is correct without actually modifying $HOME
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("cannot get home dir")
|
||||
}
|
||||
|
||||
err = EnsureUpgradeDirectories()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUpgradeDirectories() error = %v", err)
|
||||
}
|
||||
|
||||
expectedDirs := []string{
|
||||
filepath.Join(homeDir, ".dws"),
|
||||
filepath.Join(homeDir, ".dws", "data"),
|
||||
filepath.Join(homeDir, ".dws", "data", "backups"),
|
||||
filepath.Join(homeDir, ".dws", "cache"),
|
||||
filepath.Join(homeDir, ".dws", "cache", "downloads"),
|
||||
}
|
||||
for _, d := range expectedDirs {
|
||||
info, err := os.Stat(d)
|
||||
if err != nil {
|
||||
t.Errorf("directory %s should exist: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%s should be a directory", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadCacheDir(t *testing.T) {
|
||||
dir := DownloadCacheDir()
|
||||
if dir == "" {
|
||||
t.Error("DownloadCacheDir() returned empty")
|
||||
}
|
||||
if !filepath.IsAbs(dir) {
|
||||
t.Errorf("DownloadCacheDir() = %q, want absolute path", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryName(t *testing.T) {
|
||||
name := BinaryName()
|
||||
if name != "dws" && name != "dws.exe" {
|
||||
t.Errorf("BinaryName() = %q, want dws or dws.exe", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateSkillMD_NestedBeatsFlat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create both flat and nested layouts; nested should take priority
|
||||
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("flat"), 0644)
|
||||
os.MkdirAll(filepath.Join(dir, "dws"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "dws", "SKILL.md"), []byte("nested"), 0644)
|
||||
|
||||
result := LocateSkillMD(dir)
|
||||
want := filepath.Join(dir, "dws")
|
||||
if result != want {
|
||||
t.Errorf("LocateSkillMD() = %q, want nested %q", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSkillDirsNotEmpty(t *testing.T) {
|
||||
if len(knownSkillDirs) == 0 {
|
||||
t.Error("knownSkillDirs is empty")
|
||||
}
|
||||
// First entry should be the primary location
|
||||
if knownSkillDirs[0] != ".agents/skills" {
|
||||
t.Errorf("first knownSkillDir = %q, want .agents/skills", knownSkillDirs[0])
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReplaceSelf atomically replaces the currently running binary with newBinaryPath.
|
||||
//
|
||||
// On Unix (macOS / Linux):
|
||||
// 1. Try atomic os.Rename (same filesystem, instant swap)
|
||||
// 2. Fallback to copy if cross-device
|
||||
//
|
||||
// On Windows the running .exe is locked by the OS, so direct overwrite fails.
|
||||
// Strategy: rename running exe → .old, then rename/copy new binary in, then
|
||||
// clean up .old (best-effort, may be cleaned on next run).
|
||||
func ReplaceSelf(newBinaryPath string) error {
|
||||
currentExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(newBinaryPath, filePermBinary); err != nil {
|
||||
return fmt.Errorf("设置权限失败: %w", err)
|
||||
}
|
||||
|
||||
if err := replaceExeFile(newBinaryPath, currentExe); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
syncParentDir(currentExe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceExeFile replaces dst with src, handling Windows file-lock semantics.
|
||||
func replaceExeFile(src, dst string) error {
|
||||
// Fast path: atomic rename (works on Unix same-filesystem, or Windows when dst is unlocked)
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return windowsReplace(src, dst)
|
||||
}
|
||||
|
||||
// Unix cross-device fallback
|
||||
return copyFile(src, dst, filePermBinary)
|
||||
}
|
||||
|
||||
// windowsReplace handles the Windows-specific case where the running exe is locked.
|
||||
// Windows allows renaming a running executable but not overwriting it.
|
||||
func windowsReplace(src, dst string) error {
|
||||
oldPath := dst + ".old"
|
||||
|
||||
// Clean up leftover .old from a previous upgrade
|
||||
os.Remove(oldPath)
|
||||
|
||||
// Move the running (locked) binary out of the way
|
||||
if err := os.Rename(dst, oldPath); err != nil {
|
||||
return fmt.Errorf("无法移动正在运行的二进制文件: %w", err)
|
||||
}
|
||||
|
||||
// Place the new binary at the target path
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
// Cross-device fallback
|
||||
if cpErr := copyFile(src, dst, filePermBinary); cpErr != nil {
|
||||
// Attempt to restore the original
|
||||
os.Rename(oldPath, dst)
|
||||
return fmt.Errorf("替换失败: %w", cpErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort cleanup; the .old file may still be locked and will be
|
||||
// removed on the next upgrade or reboot.
|
||||
os.Remove(oldPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleFiles removes leftover .old and .rollback-tmp files from
|
||||
// previous upgrades (relevant on Windows where locked files cannot be
|
||||
// deleted immediately).
|
||||
func CleanupStaleFiles() {
|
||||
exe, err := CurrentBinaryPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.Remove(exe + ".old")
|
||||
os.Remove(exe + ".rollback-tmp")
|
||||
}
|
||||
|
||||
// ExtractZip unzips zipPath contents into targetDir.
|
||||
// Contains zip-slip protection against path traversal attacks.
|
||||
func ExtractZip(zipPath, targetDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开 zip 失败: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
targetDir = filepath.Clean(targetDir)
|
||||
for _, f := range r.File {
|
||||
destPath := filepath.Join(targetDir, f.Name)
|
||||
rel, err := filepath.Rel(targetDir, destPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
continue // zip-slip guard
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(destPath, dirPermShared)
|
||||
continue
|
||||
}
|
||||
if err := extractZipEntry(f, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindBinaryInDir recursively finds the dws binary in an extracted directory.
|
||||
func FindBinaryInDir(dir string) string {
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "dws"),
|
||||
filepath.Join(dir, "dws.exe"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var found string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := info.Name()
|
||||
if name == "dws" || name == "dws.exe" {
|
||||
found = path
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, destPath string) error {
|
||||
os.MkdirAll(filepath.Dir(destPath), dirPermShared)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 zip 条目失败: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, perm os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func syncParentDir(path string) {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractZip_BasicFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "test.zip")
|
||||
targetDir := filepath.Join(dir, "extracted")
|
||||
|
||||
createTestZip(t, zipPath, map[string]string{
|
||||
"README.md": "# Hello",
|
||||
"dws/SKILL.md": "# Skill",
|
||||
"dws/data.txt": "some data",
|
||||
})
|
||||
|
||||
if err := ExtractZip(zipPath, targetDir); err != nil {
|
||||
t.Fatalf("ExtractZip() error = %v", err)
|
||||
}
|
||||
|
||||
assertFileContent(t, filepath.Join(targetDir, "README.md"), "# Hello")
|
||||
assertFileContent(t, filepath.Join(targetDir, "dws", "SKILL.md"), "# Skill")
|
||||
assertFileContent(t, filepath.Join(targetDir, "dws", "data.txt"), "some data")
|
||||
}
|
||||
|
||||
func TestExtractZip_ZipSlipProtection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "evil.zip")
|
||||
targetDir := filepath.Join(dir, "extracted")
|
||||
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
// Attempt path traversal
|
||||
ew, _ := w.Create("../../etc/passwd")
|
||||
ew.Write([]byte("root:x:0:0"))
|
||||
// Normal file
|
||||
nw, _ := w.Create("normal.txt")
|
||||
nw.Write([]byte("ok"))
|
||||
w.Close()
|
||||
f.Close()
|
||||
|
||||
if err := ExtractZip(zipPath, targetDir); err != nil {
|
||||
t.Fatalf("ExtractZip() error = %v", err)
|
||||
}
|
||||
|
||||
// Path-traversal entry should be skipped
|
||||
evilPath := filepath.Join(dir, "..", "etc", "passwd")
|
||||
if _, err := os.Stat(evilPath); err == nil {
|
||||
t.Error("zip-slip protection failed: traversal file was extracted")
|
||||
}
|
||||
|
||||
// Normal file should exist
|
||||
assertFileContent(t, filepath.Join(targetDir, "normal.txt"), "ok")
|
||||
}
|
||||
|
||||
func TestExtractZip_InvalidZip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
badPath := filepath.Join(dir, "bad.zip")
|
||||
os.WriteFile(badPath, []byte("not a zip"), 0644)
|
||||
|
||||
err := ExtractZip(badPath, filepath.Join(dir, "out"))
|
||||
if err == nil {
|
||||
t.Error("ExtractZip() expected error for invalid zip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_EmptyZip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "empty.zip")
|
||||
createTestZip(t, zipPath, map[string]string{})
|
||||
|
||||
targetDir := filepath.Join(dir, "extracted")
|
||||
if err := ExtractZip(zipPath, targetDir); err != nil {
|
||||
t.Fatalf("ExtractZip() error = %v for empty zip", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryInDir_TopLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dwsPath := filepath.Join(dir, "dws")
|
||||
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
|
||||
|
||||
got := FindBinaryInDir(dir)
|
||||
if got != dwsPath {
|
||||
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryInDir_Nested(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nested := filepath.Join(dir, "subdir")
|
||||
os.MkdirAll(nested, 0755)
|
||||
dwsPath := filepath.Join(nested, "dws")
|
||||
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
|
||||
|
||||
got := FindBinaryInDir(dir)
|
||||
if got != dwsPath {
|
||||
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryInDir_Windows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "dws.exe")
|
||||
os.WriteFile(exePath, []byte("MZ"), 0755)
|
||||
|
||||
got := FindBinaryInDir(dir)
|
||||
if got != exePath {
|
||||
t.Errorf("FindBinaryInDir() = %q, want %q", got, exePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryInDir_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "other.bin"), []byte("x"), 0755)
|
||||
|
||||
got := FindBinaryInDir(dir)
|
||||
if got != "" {
|
||||
t.Errorf("FindBinaryInDir() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
|
||||
os.WriteFile(src, []byte("copy me"), 0644)
|
||||
|
||||
if err := copyFile(src, dst, 0755); err != nil {
|
||||
t.Fatalf("copyFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) != "copy me" {
|
||||
t.Errorf("dst content = %q, want %q", string(got), "copy me")
|
||||
}
|
||||
|
||||
info, _ := os.Stat(dst)
|
||||
if info.Mode().Perm()&0755 != 0755 {
|
||||
t.Errorf("dst perm = %o, want 0755", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_SrcNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := copyFile(filepath.Join(dir, "missing"), filepath.Join(dir, "dst"), 0644)
|
||||
if err == nil {
|
||||
t.Error("copyFile() expected error for missing src")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceExeFile_AtomicRename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "new-binary")
|
||||
dst := filepath.Join(dir, "dws")
|
||||
|
||||
os.WriteFile(dst, []byte("old"), 0755)
|
||||
os.WriteFile(src, []byte("new"), 0755)
|
||||
|
||||
if err := replaceExeFile(src, dst); err != nil {
|
||||
t.Fatalf("replaceExeFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) != "new" {
|
||||
t.Errorf("dst content = %q, want %q", string(got), "new")
|
||||
}
|
||||
|
||||
// src should no longer exist (it was renamed)
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Error("src should have been renamed away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsReplace_Basic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "new-binary")
|
||||
dst := filepath.Join(dir, "dws.exe")
|
||||
|
||||
os.WriteFile(dst, []byte("old-exe"), 0755)
|
||||
os.WriteFile(src, []byte("new-exe"), 0755)
|
||||
|
||||
if err := windowsReplace(src, dst); err != nil {
|
||||
t.Fatalf("windowsReplace() error = %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) != "new-exe" {
|
||||
t.Errorf("dst content = %q, want %q", string(got), "new-exe")
|
||||
}
|
||||
|
||||
// .old should have been cleaned up
|
||||
if _, err := os.Stat(dst + ".old"); err == nil {
|
||||
t.Error(".old file should have been cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsReplace_CleansUpStaleOld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "new-binary")
|
||||
dst := filepath.Join(dir, "dws.exe")
|
||||
oldPath := dst + ".old"
|
||||
|
||||
os.WriteFile(dst, []byte("current"), 0755)
|
||||
os.WriteFile(src, []byte("newer"), 0755)
|
||||
os.WriteFile(oldPath, []byte("stale-old"), 0755)
|
||||
|
||||
if err := windowsReplace(src, dst); err != nil {
|
||||
t.Fatalf("windowsReplace() error = %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) != "newer" {
|
||||
t.Errorf("dst content = %q, want %q", string(got), "newer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleFiles(t *testing.T) {
|
||||
// Just verify it doesn't panic; the actual files to clean depend on os.Executable()
|
||||
CleanupStaleFiles()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func createTestZip(t *testing.T, zipPath string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
for name, content := range files {
|
||||
fw, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte(content))
|
||||
}
|
||||
w.Close()
|
||||
f.Close()
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("cannot read %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("content of %s = %q, want %q", path, string(got), want)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultMaxBackups = 5
|
||||
|
||||
// BackupInfo contains information about a single backup.
|
||||
type BackupInfo struct {
|
||||
Path string `json:"path"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// RollbackManager manages backup and rollback operations.
|
||||
type RollbackManager struct {
|
||||
backupDir string
|
||||
maxBackups int
|
||||
}
|
||||
|
||||
// NewRollbackManager creates a rollback manager using the standard backup directory.
|
||||
func NewRollbackManager() *RollbackManager {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
homeDir = "."
|
||||
}
|
||||
return &RollbackManager{
|
||||
backupDir: filepath.Join(homeDir, ".dws", "data", "backups"),
|
||||
maxBackups: defaultMaxBackups,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRollbackManagerWithDir creates a rollback manager with a custom directory.
|
||||
func NewRollbackManagerWithDir(backupDir string) *RollbackManager {
|
||||
return &RollbackManager{
|
||||
backupDir: backupDir,
|
||||
maxBackups: defaultMaxBackups,
|
||||
}
|
||||
}
|
||||
|
||||
// Backup creates a backup of the currently running binary.
|
||||
// Returns the backup directory path.
|
||||
func (r *RollbackManager) Backup(currentVersion string) (string, error) {
|
||||
currentExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(currentExe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法读取当前二进制信息: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(r.backupDir, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
backupSetName := fmt.Sprintf("v%s-%s", currentVersion, timestamp)
|
||||
backupSetPath := filepath.Join(r.backupDir, backupSetName)
|
||||
|
||||
if err := os.MkdirAll(backupSetPath, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建备份集目录失败: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupDir := filepath.Join(backupSetPath, "binary")
|
||||
if err := os.MkdirAll(binaryBackupDir, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建二进制备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupPath := filepath.Join(binaryBackupDir, filepath.Base(currentExe))
|
||||
if err := copyFile(currentExe, binaryBackupPath, info.Mode()); err != nil {
|
||||
return "", fmt.Errorf("备份二进制失败: %w", err)
|
||||
}
|
||||
|
||||
backupInfo := BackupInfo{
|
||||
Path: backupSetPath,
|
||||
BinaryPath: binaryBackupPath,
|
||||
Version: currentVersion,
|
||||
CreatedAt: time.Now(),
|
||||
Size: info.Size(),
|
||||
}
|
||||
r.saveBackupInfo(backupInfo)
|
||||
|
||||
return backupSetPath, nil
|
||||
}
|
||||
|
||||
// Rollback restores the most recent backup.
|
||||
func (r *RollbackManager) Rollback() error {
|
||||
backups, err := r.ListBackups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
return fmt.Errorf("没有可用的备份")
|
||||
}
|
||||
return r.RollbackTo(backups[0])
|
||||
}
|
||||
|
||||
// RollbackTo restores a specific backup.
|
||||
// Uses replaceExeFile to handle Windows file-lock semantics correctly.
|
||||
func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
|
||||
currentExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupPath := backup.BinaryPath
|
||||
if binaryBackupPath == "" {
|
||||
binaryBackupPath = filepath.Join(backup.Path, "binary", BinaryName())
|
||||
}
|
||||
|
||||
if _, err := os.Stat(binaryBackupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("备份文件不存在: %s", binaryBackupPath)
|
||||
}
|
||||
|
||||
// Copy backup to a temp file first so replaceExeFile can use rename
|
||||
tmpPath := currentExe + ".rollback-tmp"
|
||||
if err := copyFile(binaryBackupPath, tmpPath, filePermBinary); err != nil {
|
||||
return fmt.Errorf("准备回滚文件失败: %w", err)
|
||||
}
|
||||
|
||||
if err := replaceExeFile(tmpPath, currentExe); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("恢复二进制失败: %w", err)
|
||||
}
|
||||
|
||||
syncFileData(currentExe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns all available backups, newest first.
|
||||
func (r *RollbackManager) ListBackups() ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(r.backupDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
var backups []BackupInfo
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
backupPath := filepath.Join(r.backupDir, entry.Name())
|
||||
info, err := r.loadBackupInfo(backupPath)
|
||||
if err != nil {
|
||||
fi, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
info = BackupInfo{
|
||||
Path: backupPath,
|
||||
Version: parseVersionFromBackupName(entry.Name()),
|
||||
CreatedAt: fi.ModTime(),
|
||||
}
|
||||
}
|
||||
backups = append(backups, info)
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].CreatedAt.After(backups[j].CreatedAt)
|
||||
})
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// Cleanup removes old backups, keeping only the most recent N.
|
||||
func (r *RollbackManager) Cleanup(keep int) error {
|
||||
backups, err := r.ListBackups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keep <= 0 {
|
||||
keep = r.maxBackups
|
||||
}
|
||||
for i := keep; i < len(backups); i++ {
|
||||
os.RemoveAll(backups[i].Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RollbackManager) saveBackupInfo(info BackupInfo) {
|
||||
infoPath := filepath.Join(info.Path, "info.json")
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.WriteFile(infoPath, data, filePermConfig)
|
||||
}
|
||||
|
||||
func (r *RollbackManager) loadBackupInfo(backupSetPath string) (BackupInfo, error) {
|
||||
infoPath := filepath.Join(backupSetPath, "info.json")
|
||||
data, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
var info BackupInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseVersionFromBackupName extracts version from "v0.2.7-20260314-100523".
|
||||
func parseVersionFromBackupName(name string) string {
|
||||
if len(name) > 1 && name[0] == 'v' {
|
||||
// Find first '-' after version digits
|
||||
for i := 1; i < len(name); i++ {
|
||||
if name[i] == '-' {
|
||||
// Check if next char is a digit (timestamp), meaning this is the separator
|
||||
if i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
|
||||
return name[1:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return name[1:]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func syncFileData(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory from src to dst.
|
||||
func copyDir(src, dst string) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
srcFile.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
srcFile.Close()
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseVersionFromBackupName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"v1.0.6-20260401-093000", "1.0.6"},
|
||||
{"v0.2.7-20260314-100523", "0.2.7"},
|
||||
{"v1.0.0", "1.0.0"},
|
||||
{"invalid", "unknown"},
|
||||
{"v", "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseVersionFromBackupName(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseVersionFromBackupName(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerBackupAndList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create a fake binary to backup
|
||||
fakeExe := filepath.Join(dir, "dws")
|
||||
os.WriteFile(fakeExe, []byte("#!/bin/sh\necho fake"), 0755)
|
||||
|
||||
// ListBackups on empty dir
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBackups() error = %v", err)
|
||||
}
|
||||
if len(backups) != 0 {
|
||||
t.Errorf("ListBackups() on empty = %d, want 0", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create backup directories with proper timestamps in info.json
|
||||
names := []string{"v1.0.1-20260101-010000", "v1.0.2-20260102-020000", "v1.0.3-20260103-030000"}
|
||||
for i, name := range names {
|
||||
backupDir := filepath.Join(dir, name)
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
info := BackupInfo{
|
||||
Path: backupDir,
|
||||
Version: "1.0." + string(rune('1'+i)),
|
||||
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
|
||||
}
|
||||
rm.saveBackupInfo(info)
|
||||
}
|
||||
|
||||
// Keep only 1 (the newest)
|
||||
if err := rm.Cleanup(1); err != nil {
|
||||
t.Fatalf("Cleanup(1) error = %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("after Cleanup(1), %d entries remain, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerCleanup_KeepAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
name := "v1.0." + string(rune('0'+i)) + "-20260101-01000" + string(rune('0'+i))
|
||||
backupDir := filepath.Join(dir, name)
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
rm.saveBackupInfo(BackupInfo{
|
||||
Path: backupDir,
|
||||
Version: "1.0." + string(rune('0'+i)),
|
||||
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
|
||||
})
|
||||
}
|
||||
|
||||
if err := rm.Cleanup(10); err != nil {
|
||||
t.Fatalf("Cleanup(10) error = %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("after Cleanup(10), %d entries remain, want 3", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerCleanup_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
if err := rm.Cleanup(1); err != nil {
|
||||
t.Fatalf("Cleanup on empty dir error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_WithInfoJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
backupDir := filepath.Join(dir, "v1.0.5-20260315-120000")
|
||||
os.MkdirAll(filepath.Join(backupDir, "binary"), 0755)
|
||||
os.WriteFile(filepath.Join(backupDir, "binary", "dws"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
info := BackupInfo{
|
||||
Path: backupDir,
|
||||
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
|
||||
Version: "1.0.5",
|
||||
CreatedAt: mustParseTime(t, "2026-03-15T12:00:00Z"),
|
||||
Size: 1234,
|
||||
}
|
||||
rm.saveBackupInfo(info)
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBackups() error = %v", err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("ListBackups() len = %d, want 1", len(backups))
|
||||
}
|
||||
if backups[0].Version != "1.0.5" {
|
||||
t.Errorf("Version = %q, want %q", backups[0].Version, "1.0.5")
|
||||
}
|
||||
if backups[0].Size != 1234 {
|
||||
t.Errorf("Size = %d, want 1234", backups[0].Size)
|
||||
}
|
||||
if backups[0].BinaryPath == "" {
|
||||
t.Error("BinaryPath should be set from info.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_WithoutInfoJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create a backup dir without info.json => should fallback to name parsing
|
||||
backupDir := filepath.Join(dir, "v1.0.3-20260101-100000")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBackups() error = %v", err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(backups))
|
||||
}
|
||||
if backups[0].Version != "1.0.3" {
|
||||
t.Errorf("fallback Version = %q, want 1.0.3", backups[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_SortOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
timestamps := []string{
|
||||
"2026-01-01T01:00:00Z",
|
||||
"2026-03-15T12:00:00Z",
|
||||
"2026-02-10T06:00:00Z",
|
||||
}
|
||||
for i, ts := range timestamps {
|
||||
name := "v1.0." + string(rune('0'+i)) + "-backup"
|
||||
backupDir := filepath.Join(dir, name)
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
rm.saveBackupInfo(BackupInfo{
|
||||
Path: backupDir,
|
||||
Version: "1.0." + string(rune('0'+i)),
|
||||
CreatedAt: mustParseTime(t, ts),
|
||||
})
|
||||
}
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != 3 {
|
||||
t.Fatalf("len = %d", len(backups))
|
||||
}
|
||||
// Should be sorted newest first
|
||||
if backups[0].Version != "1.0.1" {
|
||||
t.Errorf("newest = %q, want 1.0.1 (March)", backups[0].Version)
|
||||
}
|
||||
if backups[2].Version != "1.0.0" {
|
||||
t.Errorf("oldest = %q, want 1.0.0 (January)", backups[2].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_NonexistentDir(t *testing.T) {
|
||||
rm := NewRollbackManagerWithDir("/tmp/nonexistent-dir-" + time.Now().Format("20060102150405"))
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v (should return nil for nonexistent dir)", err)
|
||||
}
|
||||
if backups != nil {
|
||||
t.Errorf("should return nil for nonexistent dir, got %d entries", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_SkipsRegularFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create a regular file (not a directory) — should be ignored
|
||||
os.WriteFile(filepath.Join(dir, "not-a-backup.txt"), []byte("ignore"), 0644)
|
||||
|
||||
// Create an actual backup directory
|
||||
backupDir := filepath.Join(dir, "v1.0.0-20260101-000000")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
rm.saveBackupInfo(BackupInfo{
|
||||
Path: backupDir,
|
||||
Version: "1.0.0",
|
||||
CreatedAt: mustParseTime(t, "2026-01-01T00:00:00Z"),
|
||||
})
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("len = %d, want 1 (file should be ignored)", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadBackupInfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
backupDir := filepath.Join(dir, "v1.0.0-test")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
|
||||
original := BackupInfo{
|
||||
Path: backupDir,
|
||||
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
|
||||
Version: "1.0.0",
|
||||
CreatedAt: mustParseTime(t, "2026-06-01T10:00:00Z"),
|
||||
Size: 5678,
|
||||
}
|
||||
rm.saveBackupInfo(original)
|
||||
|
||||
loaded, err := rm.loadBackupInfo(backupDir)
|
||||
if err != nil {
|
||||
t.Fatalf("loadBackupInfo() error = %v", err)
|
||||
}
|
||||
if loaded.Version != original.Version {
|
||||
t.Errorf("Version = %q, want %q", loaded.Version, original.Version)
|
||||
}
|
||||
if loaded.Size != original.Size {
|
||||
t.Errorf("Size = %d, want %d", loaded.Size, original.Size)
|
||||
}
|
||||
if loaded.BinaryPath != original.BinaryPath {
|
||||
t.Errorf("BinaryPath = %q, want %q", loaded.BinaryPath, original.BinaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackupInfo_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
backupDir := filepath.Join(dir, "v1.0.0-bad")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
os.WriteFile(filepath.Join(backupDir, "info.json"), []byte("{bad json"), 0644)
|
||||
|
||||
_, err := rm.loadBackupInfo(backupDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRollbackManager(t *testing.T) {
|
||||
rm := NewRollbackManager()
|
||||
if rm.backupDir == "" {
|
||||
t.Error("backupDir should not be empty")
|
||||
}
|
||||
if rm.maxBackups != defaultMaxBackups {
|
||||
t.Errorf("maxBackups = %d, want %d", rm.maxBackups, defaultMaxBackups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDir(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
os.WriteFile(filepath.Join(src, "file1.txt"), []byte("content1"), 0644)
|
||||
os.MkdirAll(filepath.Join(src, "sub"), 0755)
|
||||
os.WriteFile(filepath.Join(src, "sub", "file2.txt"), []byte("content2"), 0644)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "copy")
|
||||
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
t.Fatalf("copyDir() error = %v", err)
|
||||
}
|
||||
|
||||
got1, _ := os.ReadFile(filepath.Join(dst, "file1.txt"))
|
||||
if string(got1) != "content1" {
|
||||
t.Errorf("file1 = %q, want content1", string(got1))
|
||||
}
|
||||
got2, _ := os.ReadFile(filepath.Join(dst, "sub", "file2.txt"))
|
||||
if string(got2) != "content2" {
|
||||
t.Errorf("sub/file2 = %q, want content2", string(got2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDir_EmptySrc(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
dst := filepath.Join(t.TempDir(), "copy")
|
||||
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
t.Fatalf("copyDir() error = %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dst)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("dst should be empty, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDir_SrcNotExist(t *testing.T) {
|
||||
err := copyDir("/tmp/nonexistent-src-"+time.Now().Format("150405"), t.TempDir())
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent src")
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
ts, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifySHA256 verifies a file against its expected SHA256 hash.
|
||||
func VerifySHA256(filePath, expectedHash string) error {
|
||||
actual, err := ComputeSHA256(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedHash = strings.ToLower(strings.TrimSpace(expectedHash))
|
||||
if actual != expectedHash {
|
||||
return fmt.Errorf("SHA256 校验失败: 期望 %s..., 实际 %s...", expectedHash[:16], actual[:16])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeSHA256 computes the SHA256 hash of a file.
|
||||
func ComputeSHA256(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// ParseChecksumFile parses a SHA256SUMS-style file.
|
||||
// Each line: "hash filename" (two spaces or whitespace separated).
|
||||
// Returns a map of filename -> lowercase hex hash.
|
||||
func ParseChecksumFile(content string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
hash := strings.ToLower(parts[0])
|
||||
filename := parts[1]
|
||||
result[filename] = hash
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// VerifyFileFromChecksums verifies a downloaded file against checksums.txt content.
|
||||
func VerifyFileFromChecksums(filePath, filename, checksumsContent string) error {
|
||||
checksums := ParseChecksumFile(checksumsContent)
|
||||
expectedHash, ok := checksums[filename]
|
||||
if !ok {
|
||||
return fmt.Errorf("在校验和文件中未找到 %s", filename)
|
||||
}
|
||||
return VerifySHA256(filePath, expectedHash)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeSHA256(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile")
|
||||
content := []byte("hello world")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
hash, err := ComputeSHA256(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeSHA256() error = %v", err)
|
||||
}
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
want := hex.EncodeToString(expected[:])
|
||||
if hash != want {
|
||||
t.Errorf("ComputeSHA256() = %q, want %q", hash, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySHA256(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile")
|
||||
content := []byte("hello world")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
hash := hex.EncodeToString(expected[:])
|
||||
|
||||
if err := VerifySHA256(path, hash); err != nil {
|
||||
t.Errorf("VerifySHA256() with correct hash: error = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifySHA256(path, "0000000000000000000000000000000000000000000000000000000000000000"); err == nil {
|
||||
t.Error("VerifySHA256() with wrong hash: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumFile(t *testing.T) {
|
||||
content := `abc123def456 dws-darwin-arm64.tar.gz
|
||||
fedcba987654 dws-linux-amd64.tar.gz
|
||||
112233445566 dws-skills.zip
|
||||
`
|
||||
|
||||
result := ParseChecksumFile(content)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("len(result) = %d, want 3", len(result))
|
||||
}
|
||||
if result["dws-darwin-arm64.tar.gz"] != "abc123def456" {
|
||||
t.Errorf("darwin hash = %q", result["dws-darwin-arm64.tar.gz"])
|
||||
}
|
||||
if result["dws-skills.zip"] != "112233445566" {
|
||||
t.Errorf("skills hash = %q", result["dws-skills.zip"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileFromChecksums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "myfile.bin")
|
||||
content := []byte("test content")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
hash := hex.EncodeToString(expected[:])
|
||||
checksums := hash + " myfile.bin\nother otherfile.bin\n"
|
||||
|
||||
if err := VerifyFileFromChecksums(path, "myfile.bin", checksums); err != nil {
|
||||
t.Errorf("VerifyFileFromChecksums() with correct hash: error = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifyFileFromChecksums(path, "missing.bin", checksums); err == nil {
|
||||
t.Error("VerifyFileFromChecksums() with missing file: expected error")
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompareVersions compares two semver strings (e.g. "1.0.5", "v1.0.6-beta").
|
||||
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
// Only the numeric major.minor.patch segments are compared; prerelease suffixes
|
||||
// after "-" are stripped before comparison.
|
||||
func CompareVersions(a, b string) int {
|
||||
pa := parseVersionParts(a)
|
||||
pb := parseVersionParts(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if pa[i] < pb[i] {
|
||||
return -1
|
||||
}
|
||||
if pa[i] > pb[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// NeedsUpgrade returns true when remoteVersion is newer than currentVersion.
|
||||
func NeedsUpgrade(currentVersion, remoteVersion string) bool {
|
||||
return CompareVersions(currentVersion, remoteVersion) < 0
|
||||
}
|
||||
|
||||
func parseVersionParts(v string) [3]int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if idx := strings.IndexByte(v, '-'); idx >= 0 {
|
||||
v = v[:idx]
|
||||
}
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
var result [3]int
|
||||
for i, p := range parts {
|
||||
if i < 3 {
|
||||
result[i], _ = strconv.Atoi(p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"1.0.0", "1.0.0", 0},
|
||||
{"1.0.0", "1.0.1", -1},
|
||||
{"1.0.1", "1.0.0", 1},
|
||||
{"0.2.7", "0.3.0", -1},
|
||||
{"1.0.0", "0.9.9", 1},
|
||||
{"v1.0.6", "1.0.6", 0},
|
||||
{"v1.0.5", "v1.0.6", -1},
|
||||
{"1.0.6-beta", "1.0.6", 0},
|
||||
{"1.0.5-rc1", "1.0.6-beta", -1},
|
||||
{"2.0.0", "1.99.99", 1},
|
||||
{"0.0.1", "0.0.0", 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := CompareVersions(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsUpgrade(t *testing.T) {
|
||||
tests := []struct {
|
||||
current, remote string
|
||||
want bool
|
||||
}{
|
||||
{"v1.0.6", "1.0.6", false},
|
||||
{"v1.0.5", "1.0.6", true},
|
||||
{"v1.0.7", "1.0.6", false},
|
||||
{"v1.0.6", "1.0.7", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := NeedsUpgrade(tt.current, tt.remote)
|
||||
if got != tt.want {
|
||||
t.Errorf("NeedsUpgrade(%q, %q) = %v, want %v", tt.current, tt.remote, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package cli exposes public entry points for the DWS CLI so that external
|
||||
// overlay modules can embed and launch the CLI after customising edition hooks.
|
||||
package cli
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
|
||||
// SetVersion overrides the version, build time and git commit strings
|
||||
// that are displayed by `dws version` and `dws --version`.
|
||||
// Typically called by overlay main.go with values injected via ldflags.
|
||||
func SetVersion(v, buildTime, gitCommit string) {
|
||||
app.SetVersion(v, buildTime, gitCommit)
|
||||
}
|
||||
|
||||
// Execute runs the root CLI command and returns the process exit code.
|
||||
func Execute() int {
|
||||
return app.Execute()
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// FlagFixResult holds the result of SuggestFlagFix analysis.
|
||||
type FlagFixResult struct {
|
||||
Suggestion string
|
||||
AutoFixFlag string
|
||||
AutoFixValue string
|
||||
}
|
||||
|
||||
// CommonFlagAliases maps commonly misused flag names to their correct equivalents.
|
||||
var CommonFlagAliases = map[string]string{
|
||||
"json": "format json",
|
||||
"output": "format",
|
||||
"out": "format",
|
||||
"o": "format",
|
||||
"silent": "quiet",
|
||||
"dry": "dry-run",
|
||||
"force": "yes",
|
||||
"f": "yes",
|
||||
"timeout-seconds": "timeout",
|
||||
"device-flow": "device",
|
||||
"deviceflow": "device",
|
||||
}
|
||||
|
||||
// DetectNumericTypeError checks if err is a Cobra/pflag numeric type
|
||||
// validation error. Returns the flag name and the bad value if detected.
|
||||
func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "strconv.Parse") {
|
||||
return "", "", false
|
||||
}
|
||||
const argPrefix = "invalid argument \""
|
||||
argIdx := strings.Index(msg, argPrefix)
|
||||
if argIdx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
afterArg := msg[argIdx+len(argPrefix):]
|
||||
argEnd := strings.Index(afterArg, "\"")
|
||||
if argEnd < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
badVal := afterArg[:argEnd]
|
||||
|
||||
const marker = "\" for \"--"
|
||||
idx := strings.Index(msg, marker)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
rest := msg[idx+len(marker):]
|
||||
endIdx := strings.Index(rest, "\" flag")
|
||||
if endIdx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return rest[:endIdx], badVal, true
|
||||
}
|
||||
|
||||
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
|
||||
// and Levenshtein-close typos.
|
||||
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
msg := flagErr.Error()
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(msg, prefix)
|
||||
if idx < 0 {
|
||||
return FlagFixResult{}
|
||||
}
|
||||
body := strings.TrimSpace(msg[idx+len(prefix):])
|
||||
|
||||
if alias, ok := CommonFlagAliases[body]; ok {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s? Run '%s --help' for options", alias, cmd.CommandPath())}
|
||||
}
|
||||
|
||||
var bestFlag, bestValue string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
name := f.Name
|
||||
if strings.HasPrefix(body, name) && len(body) > len(name) {
|
||||
if len(name) > len(bestFlag) {
|
||||
bestFlag = name
|
||||
bestValue = body[len(name):]
|
||||
}
|
||||
}
|
||||
})
|
||||
if bestFlag != "" {
|
||||
canAutoFix := len(bestValue) > 0
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
if canAutoFix {
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
}
|
||||
return FlagFixResult{Suggestion: suggestion}
|
||||
}
|
||||
|
||||
bestName, bestDist := "", 999
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
d := LevenshteinDist(body, f.Name)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestName = f.Name
|
||||
}
|
||||
})
|
||||
threshold := LevenshteinThreshold(len(body))
|
||||
if bestDist > 0 && bestDist <= threshold && bestName != "" {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
|
||||
}
|
||||
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
|
||||
}
|
||||
|
||||
// LevenshteinThreshold returns the max edit distance allowed based on string length.
|
||||
func LevenshteinThreshold(nameLen int) int {
|
||||
if nameLen <= 3 {
|
||||
return 1
|
||||
}
|
||||
if nameLen <= 8 {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// LevenshteinDist returns the edit distance between two strings.
|
||||
func LevenshteinDist(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
dp := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
dp[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
prev := dp[0]
|
||||
dp[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
temp := dp[j]
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
|
||||
prev = temp
|
||||
}
|
||||
}
|
||||
return dp[lb]
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package cmdutil provides reusable CLI helper functions for Cobra commands.
|
||||
// Both the open-source CLI and private overlays import this package to avoid
|
||||
// duplicating flag validation, time parsing, and UX helpers.
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GroupRunE is a reusable RunE for parent (group) commands that have no
|
||||
// business logic of their own. With args it returns an error listing available
|
||||
// subcommands; without args it shows help.
|
||||
func GroupRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
var names []string
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.Hidden {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help",
|
||||
args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath())
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint.
|
||||
func HintSubCmd(use, hint string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: use,
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("ambiguous command %q for %q\n hint: %s",
|
||||
use, cmd.Parent().CommandPath(), hint)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MustGetFlag retrieves a string flag value, checking both local and inherited flags.
|
||||
func MustGetFlag(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
if val == "" {
|
||||
val, _ = cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// FlagOrFallback reads the primary flag; if empty, falls back through alias
|
||||
// flags in order, returning the first non-empty value.
|
||||
func FlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
|
||||
if v, _ := cmd.Flags().GetString(primary); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if v, _ := cmd.Flags().GetString(alias); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MustFlagOrFallback works like FlagOrFallback but returns an error when all
|
||||
// flags are empty.
|
||||
func MustFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
|
||||
v := FlagOrFallback(cmd, primary, aliases...)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("flag --%s is required\n hint: %s", primary, cmd.Example)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// MustFlagWithHint returns an error with an explicit usage example when the
|
||||
// flag is empty.
|
||||
func MustFlagWithHint(cmd *cobra.Command, name, example string) (string, error) {
|
||||
v := MustGetFlag(cmd, name)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("flag --%s is required\n hint: %s", name, example)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ValidateRequiredFlags checks that all named string flags are non-empty.
|
||||
// Returns a formatted error listing all missing flags, or nil.
|
||||
func ValidateRequiredFlags(cmd *cobra.Command, names ...string) error {
|
||||
var missing []string
|
||||
for _, name := range names {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
if v == "" {
|
||||
missing = append(missing, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("missing required flag(s): %s\n usage: %s\n example:\n%s",
|
||||
strings.Join(missing, ", "), cmd.UseLine(), cmd.Example)
|
||||
}
|
||||
|
||||
// ValidateRequiredFlagWithAliases checks that at least one of the primary flag
|
||||
// or its aliases is non-empty.
|
||||
func ValidateRequiredFlagWithAliases(cmd *cobra.Command, primary string, aliases ...string) error {
|
||||
if FlagOrFallback(cmd, primary, aliases...) != "" {
|
||||
return nil
|
||||
}
|
||||
all := append([]string{primary}, aliases...)
|
||||
return fmt.Errorf("missing required flag: --%s (or %s)\n usage: %s\n example:\n%s",
|
||||
primary, "--"+strings.Join(all[1:], " / --"), cmd.UseLine(), cmd.Example)
|
||||
}
|
||||
|
||||
// ConfirmDelete asks for interactive confirmation before destructive operations.
|
||||
// Returns true if --yes/-y flag is set or the user types "yes"/"y".
|
||||
func ConfirmDelete(cmd *cobra.Command, resourceType, resourceName string) bool {
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); yes {
|
||||
return true
|
||||
}
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--yes" || arg == "-y" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "About to delete %s: %s\n", resourceType, resourceName)
|
||||
fmt.Fprint(cmd.ErrOrStderr(), "Confirm deletion? (yes/no): ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer == "yes" || answer == "y" {
|
||||
return true
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
|
||||
return false
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlexTimeLayouts is the ordered list of time formats tried by ParseISOTimeToMillis.
|
||||
var FlexTimeLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02",
|
||||
"20060102",
|
||||
}
|
||||
|
||||
// ParseISOTimeToMillis parses a time string into a millisecond Unix timestamp.
|
||||
// Supports RFC3339, UTC Z, timezone-less, space-separated, date-only, and more.
|
||||
// When the input lacks an explicit timezone, Asia/Shanghai is assumed.
|
||||
func ParseISOTimeToMillis(flagName, value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, "\"'")
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("flag --%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, fmt.Errorf(
|
||||
"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 fmt.Errorf("--end must be after --start\n hint: swap the values or adjust the time range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package edition
|
||||
|
||||
// defaultHooks returns the open-source edition defaults.
|
||||
// All function hooks are nil, which the internal code interprets as
|
||||
// "use standard open-source behaviour".
|
||||
func defaultHooks() *Hooks {
|
||||
return &Hooks{
|
||||
Name: "open",
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package edition provides an extension-point mechanism that allows private
|
||||
// overlay modules (e.g. an internal distribution) to customise CLI behaviour
|
||||
// without modifying the open-source core. The open-source build uses the
|
||||
// zero-value defaults; an overlay calls Override before Execute.
|
||||
package edition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ServerInfo describes a static MCP server endpoint.
|
||||
type ServerInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Endpoint string
|
||||
Prefixes []string
|
||||
}
|
||||
|
||||
// ContentBlock is a single content item in a ToolResult.
|
||||
type ContentBlock struct {
|
||||
Type string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ToolResult holds the response from an MCP tool call.
|
||||
type ToolResult struct {
|
||||
Content []ContentBlock
|
||||
}
|
||||
|
||||
// ToolCaller abstracts MCP tool invocation so private overlays can call MCP
|
||||
// tools without importing internal packages. The open-source core provides a
|
||||
// concrete adapter wrapping executor.Runner.
|
||||
type ToolCaller interface {
|
||||
// CallTool invokes an MCP tool by product ID and tool name.
|
||||
CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error)
|
||||
// Format returns the current output format ("json", "table", "raw").
|
||||
Format() string
|
||||
// DryRun returns true when --dry-run is active.
|
||||
DryRun() bool
|
||||
}
|
||||
|
||||
// Hooks groups all edition-specific behavioural overrides. Zero values
|
||||
// fall back to open-source defaults so the struct is safe to use as-is.
|
||||
type Hooks struct {
|
||||
// --- identity ---
|
||||
Name string // "open" (default) / overlay identifier
|
||||
ScenarioCode string // injected into x-dingtalk-scenario-code header
|
||||
|
||||
// --- runtime mode ---
|
||||
IsEmbedded bool // true when running inside a host application
|
||||
HideAuthLogin bool // true suppresses the "dws auth login" command
|
||||
AutoPurgeToken bool // true deletes local token data on expiry
|
||||
|
||||
// --- paths ---
|
||||
ConfigDir func() string // custom config directory; nil → ~/.dws
|
||||
|
||||
// --- HTTP headers ---
|
||||
MergeHeaders func(base map[string]string) map[string]string
|
||||
|
||||
// --- auth hooks ---
|
||||
OnAuthError func(configDir string, err error) error
|
||||
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
|
||||
|
||||
// --- product & endpoint ---
|
||||
StaticServers func() []ServerInfo // non-nil → skip Market discovery
|
||||
VisibleProducts func() []string // non-nil → override help visibility
|
||||
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
current = defaultHooks()
|
||||
)
|
||||
|
||||
// Get returns the active edition hooks (never nil).
|
||||
func Get() *Hooks {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return current
|
||||
}
|
||||
|
||||
// Override replaces the active edition hooks. Must be called before Execute.
|
||||
func Override(h *Hooks) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
current = h
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package editiontest provides contract-test helpers that any edition.Hooks
|
||||
// implementation (open-source or overlay) must satisfy. It lives in a
|
||||
// non-_test package so external modules can import and run these checks.
|
||||
package editiontest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// RunContractTests validates that the supplied Hooks implementation
|
||||
// satisfies the behavioural contract required by the open-source core.
|
||||
// Call this from both the open-source test suite and any overlay's tests.
|
||||
func RunContractTests(t *testing.T, h *edition.Hooks) {
|
||||
t.Helper()
|
||||
|
||||
if h == nil {
|
||||
t.Fatal("Hooks must not be nil")
|
||||
}
|
||||
|
||||
t.Run("Name", func(t *testing.T) {
|
||||
if h.Name == "" {
|
||||
t.Error("Hooks.Name must be non-empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConfigDir", func(t *testing.T) {
|
||||
if h.ConfigDir != nil {
|
||||
dir := h.ConfigDir()
|
||||
if dir == "" {
|
||||
t.Error("ConfigDir() must return a non-empty path when set")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MergeHeaders_preserves_base", func(t *testing.T) {
|
||||
if h.MergeHeaders == nil {
|
||||
return
|
||||
}
|
||||
base := map[string]string{"x-test": "value"}
|
||||
merged := h.MergeHeaders(base)
|
||||
if merged == nil {
|
||||
t.Fatal("MergeHeaders must return a non-nil map")
|
||||
}
|
||||
if merged["x-test"] != "value" {
|
||||
t.Error("MergeHeaders must preserve base headers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StaticServers_non_empty_when_set", func(t *testing.T) {
|
||||
if h.StaticServers == nil {
|
||||
return
|
||||
}
|
||||
servers := h.StaticServers()
|
||||
if len(servers) == 0 {
|
||||
t.Error("StaticServers() must return at least one server when set")
|
||||
}
|
||||
for i, s := range servers {
|
||||
if s.ID == "" || s.Endpoint == "" {
|
||||
t.Errorf("StaticServers()[%d]: ID and Endpoint must be non-empty", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VisibleProducts_non_empty_when_set", func(t *testing.T) {
|
||||
if h.VisibleProducts == nil {
|
||||
return
|
||||
}
|
||||
products := h.VisibleProducts()
|
||||
if len(products) == 0 {
|
||||
t.Error("VisibleProducts() must return at least one product when set")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package editiontest_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/editiontest"
|
||||
)
|
||||
|
||||
func TestOpenSourceDefaultHooksContract(t *testing.T) {
|
||||
editiontest.RunContractTests(t, edition.Get())
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION="v1.0.6"
|
||||
|
||||
echo "==> Running GoReleaser snapshot..."
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
echo "==> Running post-goreleaser.sh..."
|
||||
DWS_PACKAGE_VERSION=$VERSION ./scripts/release/post-goreleaser.sh
|
||||
|
||||
echo "==> Verifying artifacts..."
|
||||
echo "Binary version:"
|
||||
./dist/dws-darwin-arm64/dws version 2>/dev/null || ./dist/dws-linux-amd64/dws version
|
||||
|
||||
echo "npm package.json:"
|
||||
cat dist/npm/dingtalk-workspace-cli/package.json | grep '"version"'
|
||||
|
||||
echo "dws-skills.zip:"
|
||||
ls -lh dist/dws-skills.zip
|
||||
|
||||
echo "==> npm publish dry-run..."
|
||||
cd dist/npm/dingtalk-workspace-cli
|
||||
npm publish --access public --dry-run
|
||||
|
||||
echo "==> All checks passed!"
|
||||
@@ -53,23 +53,14 @@ detect_arch() {
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
# Priority 1: Use DWS_PACKAGE_VERSION environment variable (set by CI)
|
||||
if [ -n "$PACKAGE_VERSION" ]; then
|
||||
# Strip leading 'v' if present for semver compatibility
|
||||
printf '%s\n' "$PACKAGE_VERSION" | sed 's/^v//'
|
||||
printf '%s\n' "$PACKAGE_VERSION"
|
||||
return
|
||||
fi
|
||||
|
||||
# Priority 2: Get version from git tag (for local snapshot builds with tag)
|
||||
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
|
||||
git describe --tags --exact-match HEAD | sed 's/^v//'
|
||||
return
|
||||
fi
|
||||
|
||||
# Priority 3: Read from version.go (for local development without tag)
|
||||
version_line="$(sed -n 's/^var version = "v\{0,1\}\([^"]*\)".*/\1/p' "$ROOT/internal/app/version.go" | head -1)"
|
||||
if [ -z "$version_line" ] || [ "$version_line" = "dev" ]; then
|
||||
err "could not resolve package version - set DWS_PACKAGE_VERSION or create a git tag"
|
||||
if [ -z "$version_line" ]; then
|
||||
err "could not resolve package version from internal/app/version.go"
|
||||
fi
|
||||
printf '%s\n' "$version_line"
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
"WriteToken" + "Marker",
|
||||
"Token" + "Marker",
|
||||
"tokenJSON" + "File",
|
||||
"REWIND_" + "REQUEST_ID",
|
||||
"REWIND_" + "SESSION_ID",
|
||||
"REWIND_" + "MESSAGE_ID",
|
||||
"MergeWukong" + "MCPHeaders",
|
||||
"buildMode ==" + " \"real\"",
|
||||
}
|
||||
|
||||
var matches []string
|
||||
@@ -44,7 +39,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case ".git", ".worktrees", "node_modules", "dist", "plans":
|
||||
case ".git", ".worktrees", "node_modules", "dist":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user