diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg deleted file mode 100644 index c742bb27..00000000 --- a/.github/badges/coverage.svg +++ /dev/null @@ -1 +0,0 @@ -coverage: 52.8%coverage52.8% \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d2e7c57..638ad57d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,3 +141,41 @@ 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 diff --git a/.github/workflows/issue-to-AI-table.yml b/.github/workflows/issue-to-AI-table.yml new file mode 100644 index 00000000..216905e8 --- /dev/null +++ b/.github/workflows/issue-to-AI-table.yml @@ -0,0 +1,53 @@ +# Issue 变更推送到 Webhook +# 当有 Issue 变更时,发送指定格式的数据到 webhook +name: 📤 Issue Webhook Notification + +on: + issues: + types: [opened, reopened, closed, edited, labeled, unlabeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: 📬 Send Issue to Webhook + uses: actions/github-script@v7 + with: + script: | + const webhook = process.env.ISSUE_WEBHOOK_URL; + if (!webhook) { + console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification'); + return; + } + + const payload = context.payload; + const issue = payload.issue; + const action = payload.action; + + // 构建指定格式的数据 + const webhookPayload = { + action: action, + issue: { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + html_url: issue.html_url, + labels: (issue.labels || []).map(label => label.name) + } + }; + + const response = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(webhookPayload) + }); + + if (response.ok) { + console.log('✅ Webhook notification sent successfully'); + } else { + console.log('❌ Failed to send webhook notification:', response.status, response.statusText); + } + env: + ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }} diff --git a/.github/workflows/issue-to-dingtalk.yml b/.github/workflows/issue-to-dingtalk.yml new file mode 100644 index 00000000..0bbef4b6 --- /dev/null +++ b/.github/workflows/issue-to-dingtalk.yml @@ -0,0 +1,50 @@ +# Issue 自动同步到钉钉群 +# 当有新 Issue 时,自动发送到钉钉群(不包括 comment) +name: 🔔 Issue Notification to DingTalk + +on: + issues: + types: [opened, reopened, closed, labeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: 📬 Send Issue to DingTalk + uses: actions/github-script@v7 + with: + script: | + const webhook = process.env.DINGTALK_WEBHOOK; + if (!webhook) { + console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification'); + return; + } + + const payload = context.payload; + const issue = payload.issue; + const action = payload.action; + + // 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式) + const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`; + const content = issue.body?.substring(0, 500) || 'No description'; + const url = issue.html_url; + 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` + } + }; + + await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message) + }); + + console.log('✅ DingTalk notification sent'); + env: + DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68341d1d..cd06991a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,14 @@ 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 }} + run: | + gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index c4615d59..e58ba18f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ test/cli_compat/testdata/ credentials* plans _docs +dws.zip diff --git a/Makefile b/Makefile index 0b8e42dc..28a0d30d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -34,6 +34,9 @@ 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 diff --git a/README.md b/README.md index 06d0371d..a6608849 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Go 1.25+ License Apache-2.0 - v1.0.5 + Latest Release CI Coverage

@@ -19,7 +19,7 @@

> [!IMPORTANT] -> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below. +> **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. > > DingTalk Group QR Code @@ -28,6 +28,7 @@ - [Why dws?](#why-dws) - [Installation](#installation) +- [Upgrade](#upgrade) - [Getting Started](#getting-started) - [Quick Start](#quick-start) - [Using with Agents](#using-with-agents) @@ -65,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
Other install methods +**npm** (requires Node.js (npm/npx)): + +```bash +npm install -g dingtalk-workspace-cli +``` + **Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases). > **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run: @@ -85,80 +92,91 @@ cp dws ~/.local/bin/ # install to PATH
+## 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 +``` + +
+How it works + +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 | + +
+ ## Getting Started -### 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**. - -
-View screenshot -

- Create Application -

-
- -### 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 +```bash +dws auth login # browser opens automatically +dws auth login --device # for headless environments (Docker, SSH, CI) ``` -> `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. +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`.
-View screenshot +Organization hasn't enabled CLI access? + +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` +

- Configure Redirect URL + Apply for Access

+
-### Step 3: Publish the Application - -Click "App Release - Version Management & Release" to publish and go live. -
-View screenshot +Admin: Enable CLI access for your organization + +Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable. +

- Publish Application + CLI Access Management

+
-### Step 4: Request Whitelist Access +
+Custom App mode (CI/CD, ISV integration) -Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup. +For enterprise-managed scenarios, create your own DingTalk app: -### Step 5: Authenticate +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: ```bash dws auth login --client-id --client-secret ``` -Or via environment variables: - -```bash -export DWS_CLIENT_ID= -export DWS_CLIENT_SECRET= -dws auth login -``` - -
-Credential Configuration Priority - -`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 +Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
@@ -175,13 +193,6 @@ 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= -export DWS_CLIENT_SECRET= -dws auth login -``` - ### Agent Invocation Patterns ```bash @@ -334,22 +345,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ ## Key Services -| 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 | +| 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 | | 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` | 3 | `message` | Send/recall DING messages | +| Ding | `ding` | 2 | `message` | Send/recall DING messages | | Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics | -| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates | +| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates | | Workbench | `workbench` | 2 | `app` | Batch query app details | -| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes | +| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes | -> 104 tools across 12 products. Run `dws --help` for the full list, or `dws --help` for subcommands. +> 86 commands across 12 products. Run `dws --help` for the full list, or `dws --help` for subcommands.
Coming soon diff --git a/README_zh.md b/README_zh.md index 9b9c9b3a..b05c4905 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,7 +9,7 @@

Go 1.25+ License Apache-2.0 - v1.0.5 + Latest Release CI Coverage

@@ -19,7 +19,7 @@

> [!IMPORTANT] -> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。 +> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。 > > DingTalk Group QR Code @@ -28,6 +28,7 @@ - [为什么选择 dws?](#why-dws) - [安装](#安装) +- [升级](#升级) - [开始使用](#开始使用) - [快速开始](#快速开始) - [在 Agent 中使用](#在-agent-中使用) @@ -65,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
其他安装方式 +**npm**(需要 Node.js(npm/npx)): + +```bash +npm install -g dingtalk-workspace-cli +``` + **预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。 > **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行: @@ -85,80 +92,91 @@ cp dws ~/.local/bin/ # 安装到 PATH
+## 升级 + +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 # 跳过确认直接升级 +``` + +
+工作原理 + +升级过程采用两阶段原子流程,确保一致性: + +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` | 跳过确认提示 | + +
+ ## 开始使用 -### 步骤 1:创建钉钉应用 - -进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。 - -
-查看截图 -

- 创建应用 -

-
- -### 步骤 2:配置重定向 URL - -进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存: - -``` -http://127.0.0.1,https://login.dingtalk.com +```bash +dws auth login # 自动唤起浏览器 +dws auth login --device # 无浏览器环境(Docker、SSH、CI) ``` -> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。 +选择组织并授权即可。 + +> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
-查看截图 +组织未开启 CLI 访问权限? + +1. 选择组织后,点击「立即申请」通知管理员 +2. 管理员收到申请卡片,一键审批 +3. 审批通过后,重新执行 `dws auth login` +

- 配置重定向URL + 申请权限

+
-### 步骤 3:发布应用 - -点击「应用发布 - 版本管理与发布」,发布版本使应用上线。 -
-查看截图 +管理员:为组织开启 CLI 访问权限 + +进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。 +

- 发布应用 + CLI访问管理

+
-### 步骤 4:申请白名单 +
+自建应用模式(CI/CD、ISV 集成) -加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。 +企业自主管控场景,可创建自有钉钉应用: -### 步骤 5:登录认证 +1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用 +2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com` +3. 发布应用 +4. 登录: ```bash dws auth login --client-id --client-secret ``` -或通过环境变量: - -```bash -export DWS_CLIENT_ID= -export DWS_CLIENT_SECRET= -dws auth login -``` - -
-凭证配置优先级 - -`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 环境**:建议使用环境变量配置 +首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
@@ -175,13 +193,6 @@ dws todo task list --dry-run # 预览操作但不执行 dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境: -```bash -# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录) -export DWS_CLIENT_ID= -export DWS_CLIENT_SECRET= -dws auth login -``` - ### Agent 调用模式 ```bash @@ -334,22 +345,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ ## 核心服务 -| 服务 | 命令 | 工具数 | 子命令 | 描述 | +| 服务 | 命令 | 命令数 | 子命令 | 描述 | |------|------|:------:|--------|------| -| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 | -| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 | -| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 | +| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 | +| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook | +| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 | | 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 | | 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 | | 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 | | 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 | -| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 | +| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 | | 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 | -| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 | +| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 | | 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 | -| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 | +| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 | -> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。 +> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。
即将推出 diff --git a/go.mod b/go.mod index 2a734851..d3fd55e5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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 @@ -15,7 +16,6 @@ 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 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index ce263247..bfaf4445 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -24,8 +24,9 @@ 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" ) @@ -48,7 +49,9 @@ func buildAuthCommand() *cobra.Command { }, } - cmd.AddCommand(newAuthLoginCommand()) + if !edition.Get().HideAuthLogin { + cmd.AddCommand(newAuthLoginCommand()) + } cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), @@ -185,9 +188,19 @@ 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")) @@ -196,7 +209,9 @@ func newAuthLogoutCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 已清除所有认证信息") - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "请运行 dws auth login 重新登录") + } return nil }, } @@ -226,6 +241,8 @@ func newAuthStatusCommand() *cobra.Command { tokenData = updatedData refreshed = true } + } else if edition.Get().AutoPurgeToken { + _ = authpkg.DeleteTokenData(configDir) } } if authStatusAuthenticated(tokenData) { @@ -253,7 +270,9 @@ func newAuthStatusCommand() *cobra.Command { } } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") - fmt.Fprintln(w, "运行 dws auth login 进行登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "运行 dws auth login 进行登录") + } } return nil }, @@ -331,7 +350,9 @@ func newAuthResetCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 认证信息已重置") - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "请运行 dws auth login 重新登录") + } return nil }, } diff --git a/internal/app/config.go b/internal/app/config.go index 12015c95..68f3ae0a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -16,6 +16,8 @@ package app import ( "os" "path/filepath" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) // Build-time variables injected via ldflags when available. @@ -28,7 +30,9 @@ 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() diff --git a/internal/app/help_source_test.go b/internal/app/help_source_test.go index f8fb9614..5f9b9fe4 100644 --- a/internal/app/help_source_test.go +++ b/internal/app/help_source_test.go @@ -216,10 +216,10 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) { } } -func TestRootCommandDoesNotRegisterUpgradeCommand(t *testing.T) { +func TestRootCommandRegistersUpgradeCommand(t *testing.T) { root := NewRootCommand() - if cmd := lookupCommand(root, "upgrade"); cmd != nil { - t.Fatalf("findCommand(upgrade) = %q, want nil", cmd.CommandPath()) + if cmd := lookupCommand(root, "upgrade"); cmd == nil { + t.Fatal("upgrade command should be registered on root, but was not found") } } diff --git a/internal/app/legacy.go b/internal/app/legacy.go index 25fe605a..7de9fcd4 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -16,6 +16,7 @@ package app import ( "context" "encoding/json" + "fmt" "log/slog" "net" "net/http" @@ -29,16 +30,25 @@ 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...) } @@ -46,6 +56,26 @@ 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; @@ -59,6 +89,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob // Tests may override discoveryBaseURLOverride to redirect to a local server; // in that case the registry cache is always bypassed. func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command { + totalStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart)) + } + }() store := cacheStoreFromEnv() partition := config.DefaultPartition @@ -70,13 +106,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == "" // --- Cache-first server registry --- + cacheLoadStart := time.Now() snapshot, freshness, cacheErr := store.LoadRegistry(partition) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr) + } + var servers []market.ServerDescriptor now := store.Now().UTC() usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0 if usingCachedRegistry { - slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness) + } servers = snapshot.Servers // Only trigger async revalidation in production (no URL override). // Tests set discoveryBaseURLOverride and control cache expiry directly, @@ -92,9 +135,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C if discoveryBaseURLOverride != "" { baseURL = discoveryBaseURLOverride } - slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL) + } + fetchStart := time.Now() client := market.NewClient(baseURL, ipv4OnlyHTTPClient()) resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr) + } if fetchErr != nil { slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr) // Degrade to stale cache if available (production only). @@ -106,12 +155,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C } } else { servers = market.NormalizeServers(resp, "market") - slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers)) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers)) + } // Persist fresh data (only in non-test mode). if useCache { + saveStart := time.Now() if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil { slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr) } + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart)) + } } } } @@ -122,9 +177,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C // Inject dynamic server data for endpoint resolution SetDynamicServers(servers) + detailStart := time.Now() detailsByID := loadCachedDetailsFast(store, servers) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart)) + } + + buildStart := time.Now() cmds := compat.BuildDynamicCommands(servers, runner, detailsByID) - slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds)) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds)) + } return cmds } diff --git a/internal/app/recovery_command_test.go b/internal/app/recovery_command_test.go index 32fa6cf7..dd317b78 100644 --- a/internal/app/recovery_command_test.go +++ b/internal/app/recovery_command_test.go @@ -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"}`} + os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"} stdoutR, stdoutW, err := os.Pipe() if err != nil { diff --git a/internal/app/root.go b/internal/app/root.go index 99f0d372..f5c7e79f 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -29,7 +29,6 @@ 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" @@ -41,6 +40,8 @@ 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" ) @@ -51,19 +52,43 @@ 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)) + } + }() + 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) + } // Run PreParse handlers on raw argv before Cobra parses flags. // This corrects model-generated errors like --userId → --user-id // and --limit100 → --limit 100. pipeline.RunPreParse(root, engine) + execStart := time.Now() executed, err := root.ExecuteC() + 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) + } if err != nil { if executed == nil { executed = root @@ -121,14 +146,14 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro } // resolveVerbosity derives the error verbosity level from the root command's flags. -func resolveVerbosity(root *cobra.Command) apperrors.Verbosity { - if root == nil { +func resolveVerbosity(cmd *cobra.Command) apperrors.Verbosity { + if cmd == nil { return apperrors.VerbosityNormal } - if debug, err := root.PersistentFlags().GetBool("debug"); err == nil && debug { + if debug, err := cmd.Flags().GetBool("debug"); err == nil && debug { return apperrors.VerbosityDebug } - if verbose, err := root.PersistentFlags().GetBool("verbose"); err == nil && verbose { + if verbose, err := cmd.Flags().GetBool("verbose"); err == nil && verbose { return apperrors.VerbosityVerbose } return apperrors.VerbosityNormal @@ -250,9 +275,11 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) utilityCommands := []*cobra.Command{ newAuthCommand(), + newSkillCommand(), newCacheCommand(), newCompletionCommand(root), newRecoveryCommand(rootCtx, loader, flags), + newUpgradeCommand(), newVersionCommand(), schemaCmd, genSkillsCmd, @@ -262,6 +289,12 @@ 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 @@ -275,6 +308,10 @@ func newAuthCommand() *cobra.Command { return buildAuthCommand() } +func newSkillCommand() *cobra.Command { + return buildSkillCommand() +} + func newCacheCommand() *cobra.Command { cacheCmd := newPlaceholderParent("cache", "缓存管理") @@ -436,24 +473,51 @@ func newVersionCommand() *cobra.Command { Example: " dws version\n dws version --format json", DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - format, err := cmd.Flags().GetString("format") - if err != nil { - return apperrors.NewInternal("failed to read format flag") + wantJSON := cmd.Flags().Changed("format") + if wantJSON { + format, _ := cmd.Flags().GetString("format") + wantJSON = (format == "json") } - payload := map[string]any{ - "version": Version(), - "go": "1.24+", + + editionName := edition.Get().Name + if editionName == "" { + editionName = "open" } - if format == "json" { + 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 + } return output.WriteJSON(cmd.OutOrStdout(), payload) } - _, err = fmt.Fprintf( - cmd.OutOrStdout(), - "版本: %s\nGo: %s\n", - Version(), - "1.24+", - ) - return err + + 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 }, } } @@ -550,17 +614,30 @@ 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. +// unless they correspond to a product discovered via dynamic server discovery +// or listed in the edition's VisibleProducts hook. // Public utility commands (auth, cache, completion, version) are always kept // visible; explicitly hidden commands stay hidden. func hideNonDirectRuntimeCommands(root *cobra.Command) { - allowedProducts := DirectRuntimeProductIDs() + 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() + } 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() @@ -577,6 +654,24 @@ 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) diff --git a/internal/app/root_execute_test.go b/internal/app/root_execute_test.go index 6a3294c4..178f31f4 100644 --- a/internal/app/root_execute_test.go +++ b/internal/app/root_execute_test.go @@ -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 key:\n%s", out.String()) + if !strings.Contains(out.String(), "Version:") { + t.Fatalf("version output missing Version line:\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 key:\n%s", out.String()) + if !strings.Contains(out.String(), "Version:") { + t.Fatalf("version output missing Version line:\n%s", out.String()) } } diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 9dfe9dc7..f005d05a 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -5,6 +5,7 @@ import ( "strings" "text/tabwriter" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -53,7 +54,16 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command { return nil } - allowed := DirectRuntimeProductIDs() + 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() + } if len(allowed) == 0 { return nil } diff --git a/internal/app/runner.go b/internal/app/runner.go index 25ce2192..8c817805 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -22,6 +22,7 @@ import ( "net/http" "os" "strings" + "sync" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" @@ -30,6 +31,7 @@ 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 ( @@ -74,6 +76,13 @@ type runtimeRunner struct { } func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + totalStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart)) + } + }() + if r.loader == nil || r.transport == nil { return r.fallback.Run(ctx, invocation) } @@ -93,7 +102,9 @@ 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 } @@ -117,7 +128,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) } func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) { - tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders()) + // Lazy bind FileLogger: it may be nil at construction time because + // configureLogLevel runs later in PersistentPreRunE. + if r.transport.FileLogger == nil { + r.transport.FileLogger = FileLoggerInstance() + } + + authStart := time.Now() + authToken := r.resolveAuthToken(ctx) + authDuration := time.Since(authStart) + RecordTiming(ctx, "auth_token", authDuration) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration) + } if invocation.DryRun { return executor.Result{ @@ -148,14 +171,39 @@ 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) + } if err != nil { + if isAuthError(err) { + if fn := edition.Get().OnAuthError; fn != nil { + _ = fn(defaultConfigDir(), err) + } + } captureRuntimeFailure(invocation, err, err) return executor.Result{}, err } if callResult.IsError { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) + logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag) mcpErr := apperrors.NewAPI( extractMCPErrorMessage(callResult), apperrors.WithOperation("tools/call"), @@ -175,6 +223,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if bizErr := detectBusinessError(callResult.Content); bizErr != "" { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) + logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag) return executor.Result{}, apperrors.NewAPI(bizErr, apperrors.WithOperation("tools/call"), apperrors.WithReason("business_error"), @@ -207,25 +256,58 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string { if token := strings.TrimSpace(explicitToken); token != "" { return token } - configDir := defaultConfigDir() - provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil))) - configureOAuthProviderCompatibility(provider, configDir) - token, tokenErr := provider.GetAccessToken(ctx) - if tokenErr == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token) - } - // If the error is a decryption failure (corrupted data), surface - // it immediately instead of falling back to empty token. - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - slog.Error(tokenErr.Error()) - return "" - } - manager := authpkg.NewManager(configDir, nil) - configureLegacyAuthManagerCompatibility(manager) - if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token) - } - return "" + // Use cached token to avoid repeated Keychain access (~70ms per call) + return getCachedRuntimeToken(ctx) +} + +// Cached token state for process lifetime +var ( + cachedRuntimeToken string + cachedRuntimeTokenOnce sync.Once +) + +// getCachedRuntimeToken returns a cached access token, loading it only once per process. +// This avoids repeated Keychain access which takes ~70ms each time. +func getCachedRuntimeToken(ctx context.Context) string { + cachedRuntimeTokenOnce.Do(func() { + loadStart := time.Now() + defer func() { + 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) + } + }() + + configDir := defaultConfigDir() + provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil))) + configureOAuthProviderCompatibility(provider, configDir) + token, tokenErr := provider.GetAccessToken(ctx) + if tokenErr == nil && strings.TrimSpace(token) != "" { + cachedRuntimeToken = strings.TrimSpace(token) + return + } + // If the error is a decryption failure (corrupted data), log and bail out + if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { + slog.Error(tokenErr.Error()) + return + } + // Try legacy manager as fallback + manager := authpkg.NewManager(configDir, nil) + configureLegacyAuthManagerCompatibility(manager) + if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" { + cachedRuntimeToken = strings.TrimSpace(token) + return + } + }) + return cachedRuntimeToken +} + +// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. +// This should be called after login/logout operations. +func ResetRuntimeTokenCache() { + cachedRuntimeTokenOnce = sync.Once{} + cachedRuntimeToken = "" } func newRuntimeContentScanner() safety.Scanner { @@ -259,6 +341,14 @@ 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)) @@ -289,6 +379,9 @@ func resolveIdentityHeaders() map[string]string { headers[k] = v } } + if fn := edition.Get().MergeHeaders; fn != nil { + headers = fn(headers) + } return headers } @@ -332,3 +425,36 @@ func extractMCPErrorMessage(result transport.ToolCallResult) string { } return "MCP tool returned an error response" } + +// logBusinessError logs MCP tool errors and business errors to the file logger +// so they can be diagnosed offline. These errors arrive as HTTP 200 responses +// and would otherwise not be captured by transport-level logging. +func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) { + if logger == nil { + return + } + attrs := []any{ + "product", inv.CanonicalProduct, + "tool", inv.Tool, + "reason", reason, + } + if diag.TraceID != "" { + attrs = append(attrs, "trace_id", diag.TraceID) + } + if diag.ServerErrorCode != "" { + attrs = append(attrs, "server_error_code", diag.ServerErrorCode) + } + if diag.TechnicalDetail != "" { + attrs = append(attrs, "technical_detail", diag.TechnicalDetail) + } + if msg, ok := content["error"].(string); ok { + attrs = append(attrs, "error", msg) + } + if msg, ok := content["errorMsg"].(string); ok { + attrs = append(attrs, "errorMsg", msg) + } + if msg, ok := content["message"].(string); ok { + attrs = append(attrs, "message", msg) + } + logger.Warn("business_error", attrs...) +} diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 8434084b..d197a102 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -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"}`}) + cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}) 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"}`}) + cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}) 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"}) + cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes", "--token", "test-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) @@ -255,6 +255,37 @@ 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() @@ -415,7 +446,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"}`}) + cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--token", "test-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) @@ -465,7 +496,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"}`}) + cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) @@ -628,7 +659,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"}`}) + cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}) err := cmd.Execute() if err == nil { diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go new file mode 100644 index 00000000..4a20cbb5 --- /dev/null +++ b/internal/app/skill_command.go @@ -0,0 +1,377 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +const ( + // skillDownloadEndpoint is the API endpoint for downloading skills. + skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download" + // skillDownloadTimeout is the timeout for skill download operations. + skillDownloadTimeout = 5 * time.Minute +) + +// downloadSkillResponse represents the API response for skill download. +type downloadSkillResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result *downloadSkillResult `json:"result,omitempty"` +} + +// downloadSkillResult contains the download URL and file name. +type downloadSkillResult struct { + DownloadURL string `json:"downloadUrl"` + FileName string `json:"fileName"` +} + +// agentSkillPaths maps target names to their relative skill installation paths. +// These paths are relative to the user's home directory. +var agentSkillPaths = map[string]string{ + "qoder": ".qoder/skills", + "claude": ".claude/skills", + "cursor": ".cursor/skills", + "codex": ".codex/skills", + "opencode": filepath.Join(".config", "opencode", "skills"), +} + +// supportedTargets returns a comma-separated list of supported targets. +func supportedTargets() string { + targets := make([]string, 0, len(agentSkillPaths)+1) + for target := range agentSkillPaths { + targets = append(targets, target) + } + targets = append(targets, ".") + return strings.Join(targets, ", ") +} + +func buildSkillCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "skill", + Short: "技能管理", + Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newSkillAddCommand()) + return cmd +} + +func newSkillAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "下载并安装技能到指定目录", + Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。 + +参数: + skillId 技能 ID(必填),可从钉钉技能市场获取 + target 安装目标(必填),支持: %s + +安装路径: + qoder -> ~/.qoder/skills/ + claude -> ~/.claude/skills/ + cursor -> ~/.cursor/skills/ + codex -> ~/.codex/skills/ + opencode -> ~/.config/opencode/skills/ + . -> 当前目录 + +示例: + dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/ + dws skill add skill-123 claude # 安装到 ~/.claude/skills/ + dws skill add skill-123 . # 安装到当前目录`, supportedTargets()), + Args: cobra.ExactArgs(2), + DisableAutoGenTag: true, + RunE: runSkillAdd, + } + + return cmd +} + +func runSkillAdd(cmd *cobra.Command, args []string) error { + skillID := strings.TrimSpace(args[0]) + target := strings.TrimSpace(args[1]) + + if skillID == "" { + return apperrors.NewValidation("skillId is required") + } + + // Resolve target path + destPath, err := resolveSkillTargetPath(target) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets())) + } + + // Load auth token + configDir := defaultConfigDir() + tokenData, err := authpkg.LoadTokenData(configDir) + if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() { + return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first", + apperrors.WithHint("请先执行 'dws auth login' 登录"), + apperrors.WithActions("dws auth login")) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout) + defer cancel() + + w := cmd.OutOrStdout() + + // Step 1: Get download URL from API + fmt.Fprintf(w, "正在获取技能信息...\n") + downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID) + if err != nil { + return err + } + + if !downloadResp.Success { + errMsg := downloadResp.ErrorMsg + if errMsg == "" { + errMsg = downloadResp.ErrorCode + } + if errMsg == "" { + errMsg = "unknown error" + } + return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg), + apperrors.WithReason(downloadResp.ErrorCode)) + } + + if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" { + return apperrors.NewAPI("skill download URL not found in response") + } + + // Step 2: Download the skill zip file + fmt.Fprintf(w, "正在下载技能...\n") + tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName) + if err != nil { + return err + } + defer cleanupTempFile(tempZipPath) + + // Step 3: Extract zip to destination + fmt.Fprintf(w, "正在解压到 %s...\n", destPath) + if err := extractSkillZip(tempZipPath, destPath); err != nil { + return err + } + + fmt.Fprintf(w, "\n[OK] 技能安装成功!\n") + fmt.Fprintf(w, "安装路径: %s\n", destPath) + + return nil +} + +// resolveSkillTargetPath resolves the target argument to an absolute path. +func resolveSkillTargetPath(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("target is required") + } + + // Special case: current directory + if target == "." { + return os.Getwd() + } + + // Look up predefined agent paths + relPath, ok := agentSkillPaths[strings.ToLower(target)] + if !ok { + return "", fmt.Errorf("unsupported target") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, relPath), nil +} + +// fetchSkillDownloadInfo calls the download API to get the skill download URL. +func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) { + url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err)) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-user-access-token", accessToken) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err), + apperrors.WithRetryable(true)) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token", + apperrors.WithHint("请执行 'dws auth login' 重新登录"), + apperrors.WithActions("dws auth login")) + } + + if resp.StatusCode != http.StatusOK { + return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode), + apperrors.WithRetryable(resp.StatusCode >= 500)) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit + if err != nil { + return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err)) + } + + var result downloadSkillResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err)) + } + + return &result, nil +} + +// downloadSkillFile downloads the skill zip file to a temporary location. +func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err)) + } + + client := &http.Client{Timeout: skillDownloadTimeout} + resp, err := client.Do(req) + if err != nil { + return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err), + apperrors.WithRetryable(true)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode), + apperrors.WithRetryable(resp.StatusCode >= 500)) + } + + // Create temp file + if fileName == "" { + fileName = "skill.zip" + } + tempFile, err := os.CreateTemp("", "dws-skill-*.zip") + if err != nil { + return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err)) + } + tempPath := tempFile.Name() + + // Copy response body to temp file + _, err = io.Copy(tempFile, resp.Body) + closeErr := tempFile.Close() + if err != nil { + os.Remove(tempPath) + return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err)) + } + if closeErr != nil { + os.Remove(tempPath) + return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr)) + } + + return tempPath, nil +} + +// extractSkillZip extracts a zip file to the destination directory. +func extractSkillZip(zipPath, destDir string) error { + // Ensure destination directory exists + if err := os.MkdirAll(destDir, 0755); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err)) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err)) + } + defer reader.Close() + + for _, file := range reader.File { + if err := extractZipFile(file, destDir); err != nil { + return err + } + } + + return nil +} + +// extractZipFile extracts a single file from the zip archive. +func extractZipFile(file *zip.File, destDir string) error { + // Sanitize file path to prevent zip slip attacks + filePath := filepath.Join(destDir, file.Name) + if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) { + return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name)) + } + + if file.FileInfo().IsDir() { + // Use 0755 to ensure we have write permission for creating files inside + return os.MkdirAll(filePath, 0755) + } + + // Ensure parent directory exists with write permission + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err)) + } + + // Extract file + srcFile, err := file.Open() + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err)) + } + defer srcFile.Close() + + // Use file mode from zip but ensure at least 0644 for files + fileMode := file.Mode() + if fileMode&0600 == 0 { + fileMode = 0644 + } + destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err)) + } + defer destFile.Close() + + if _, err := io.Copy(destFile, srcFile); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err)) + } + + return nil +} + +// cleanupTempFile removes a temporary file, ignoring errors. +func cleanupTempFile(path string) { + if path != "" { + os.Remove(path) + } +} diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go new file mode 100644 index 00000000..354c010c --- /dev/null +++ b/internal/app/skill_command_test.go @@ -0,0 +1,732 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +func TestResolveSkillTargetPath(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home directory: %v", err) + } + + tests := []struct { + name string + target string + wantSuffix string + wantErr bool + }{ + { + name: "qoder target", + target: "qoder", + wantSuffix: filepath.Join(".qoder", "skills"), + wantErr: false, + }, + { + name: "claude target", + target: "claude", + wantSuffix: filepath.Join(".claude", "skills"), + wantErr: false, + }, + { + name: "cursor target", + target: "cursor", + wantSuffix: filepath.Join(".cursor", "skills"), + wantErr: false, + }, + { + name: "codex target", + target: "codex", + wantSuffix: filepath.Join(".codex", "skills"), + wantErr: false, + }, + { + name: "opencode target", + target: "opencode", + wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantErr: false, + }, + { + name: "case insensitive - QODER", + target: "QODER", + wantSuffix: filepath.Join(".qoder", "skills"), + wantErr: false, + }, + { + name: "case insensitive - Claude", + target: "Claude", + wantSuffix: filepath.Join(".claude", "skills"), + wantErr: false, + }, + { + name: "invalid target", + target: "invalid", + wantErr: true, + }, + { + name: "empty target", + target: "", + wantErr: true, + }, + { + name: "whitespace only", + target: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveSkillTargetPath(tt.target) + if (err != nil) != tt.wantErr { + t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + expected := filepath.Join(homeDir, tt.wantSuffix) + if got != expected { + t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected) + } + } + }) + } +} + +func TestResolveSkillTargetPathCurrentDir(t *testing.T) { + // Test "." target returns current working directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + got, err := resolveSkillTargetPath(".") + if err != nil { + t.Errorf("resolveSkillTargetPath(\".\") error = %v", err) + return + } + + if got != cwd { + t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd) + } +} + +func TestParseDownloadSkillResponse(t *testing.T) { + tests := []struct { + name string + jsonInput string + wantSuccess bool + wantURL string + wantFile string + wantErrCode string + wantErrMsg string + }{ + { + name: "successful response", + jsonInput: `{ + "success": true, + "result": { + "downloadUrl": "https://example.com/skill.zip", + "fileName": "my-skill.zip" + } + }`, + wantSuccess: true, + wantURL: "https://example.com/skill.zip", + wantFile: "my-skill.zip", + }, + { + name: "error response", + jsonInput: `{ + "success": false, + "errorCode": "SKILL_NOT_FOUND", + "errorMsg": "The skill does not exist" + }`, + wantSuccess: false, + wantErrCode: "SKILL_NOT_FOUND", + wantErrMsg: "The skill does not exist", + }, + { + name: "success with empty result", + jsonInput: `{ + "success": true, + "result": null + }`, + wantSuccess: true, + wantURL: "", + wantFile: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var resp downloadSkillResponse + if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if resp.Success != tt.wantSuccess { + t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess) + } + + if tt.wantSuccess && resp.Result != nil { + if resp.Result.DownloadURL != tt.wantURL { + t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL) + } + if resp.Result.FileName != tt.wantFile { + t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile) + } + } + + if !tt.wantSuccess { + if resp.ErrorCode != tt.wantErrCode { + t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode) + } + if resp.ErrorMsg != tt.wantErrMsg { + t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg) + } + } + }) + } +} + +func TestExtractSkillZip(t *testing.T) { + // Create a temporary zip file with test content + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip file with test content + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Add a file to the zip + fileContent := []byte("test content") + writer, err := zipWriter.Create("test-file.txt") + if err != nil { + t.Fatalf("failed to create file in zip: %v", err) + } + if _, err := writer.Write(fileContent); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + + // Add a subdirectory with a file + writer, err = zipWriter.Create("subdir/nested-file.txt") + if err != nil { + t.Fatalf("failed to create nested file in zip: %v", err) + } + if _, err := writer.Write([]byte("nested content")); err != nil { + t.Fatalf("failed to write nested file content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract the zip + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Fatalf("extractSkillZip() error = %v", err) + } + + // Verify extracted files + extractedFile := filepath.Join(destDir, "test-file.txt") + content, err := os.ReadFile(extractedFile) + if err != nil { + t.Errorf("failed to read extracted file: %v", err) + } + if string(content) != "test content" { + t.Errorf("extracted content = %v, want %v", string(content), "test content") + } + + // Verify nested file + nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt") + content, err = os.ReadFile(nestedFile) + if err != nil { + t.Errorf("failed to read nested file: %v", err) + } + if string(content) != "nested content" { + t.Errorf("nested content = %v, want %v", string(content), "nested content") + } +} + +func TestExtractSkillZipPreventZipSlip(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "malicious.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create a zip file with a path traversal attempt + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Try to create a file with path traversal + writer, err := zipWriter.Create("../../../etc/passwd") + if err != nil { + t.Fatalf("failed to create malicious file in zip: %v", err) + } + if _, err := writer.Write([]byte("malicious content")); err != nil { + t.Fatalf("failed to write malicious content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract should fail due to zip slip protection + err = extractSkillZip(zipPath, destDir) + if err == nil { + t.Error("extractSkillZip() should have failed for zip slip attack") + } + if !strings.Contains(err.Error(), "invalid file path") { + t.Errorf("error should mention invalid file path, got: %v", err) + } +} + +func TestSkillAddCommandValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errMsg string + }{ + { + name: "missing arguments", + args: []string{"skill", "add"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + { + name: "missing target", + args: []string{"skill", "add", "skill-123"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + { + name: "too many arguments", + args: []string{"skill", "add", "skill-123", "qoder", "extra"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs(tt.args) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if (err != nil) != tt.wantErr { + t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error = %v, should contain %v", err, tt.errMsg) + } + }) + } +} + +func TestSkillAddInvalidTarget(t *testing.T) { + // Setup: Create config directory with valid token + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + t.Setenv("DWS_CONFIG_DIR", configDir) + + // Save a valid token + err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{ + AccessToken: "test-token", + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("failed to save token data: %v", err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err = cmd.Execute() + if err == nil { + t.Error("Execute() should have failed for invalid target") + } + if !strings.Contains(err.Error(), "invalid target") { + t.Errorf("error should mention invalid target, got: %v", err) + } +} + +func TestSkillAddRequiresAuth(t *testing.T) { + // Setup: Create config directory without token + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + t.Setenv("DWS_CONFIG_DIR", configDir) + + // Ensure the config directory exists but has no token + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if err == nil { + t.Error("Execute() should have failed without auth") + } + // Check for authentication-related error (English or Chinese) + errStr := err.Error() + if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") { + t.Errorf("error should mention authentication, got: %v", err) + } +} + +func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) { + // Create mock server that returns 401 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // We can't easily test the actual fetchSkillDownloadInfo function + // because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern. + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestSupportedTargets(t *testing.T) { + targets := supportedTargets() + + // Should contain all predefined targets + expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."} + for _, expected := range expectedTargets { + if !strings.Contains(targets, expected) { + t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets) + } + } +} + +func TestAgentSkillPathsCrossPlatform(t *testing.T) { + // Verify that paths use platform-appropriate separators + for target, path := range agentSkillPaths { + if runtime.GOOS == "windows" { + if strings.Contains(path, "/") && !strings.Contains(path, "\\") { + // On Windows, filepath.Join should use backslashes + // But raw map values may use forward slashes + t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path) + } + } + + // Test that resolveSkillTargetPath produces valid paths + resolved, err := resolveSkillTargetPath(target) + if err != nil { + t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err) + continue + } + + // Path should be absolute + if !filepath.IsAbs(resolved) { + t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved) + } + } +} + +func TestCleanupTempFile(t *testing.T) { + // Create a temporary file + tempFile, err := os.CreateTemp("", "test-cleanup-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tempPath := tempFile.Name() + tempFile.Close() + + // Verify file exists + if _, err := os.Stat(tempPath); os.IsNotExist(err) { + t.Fatalf("temp file should exist before cleanup") + } + + // Clean up + cleanupTempFile(tempPath) + + // Verify file is deleted + if _, err := os.Stat(tempPath); !os.IsNotExist(err) { + t.Errorf("temp file should be deleted after cleanup") + } + + // Cleanup should not panic on empty path + cleanupTempFile("") + + // Cleanup should not panic on non-existent file + cleanupTempFile("/nonexistent/path/file.txt") +} + +func TestDownloadSkillResponseJSON(t *testing.T) { + // Test JSON marshaling/unmarshaling round-trip + original := downloadSkillResponse{ + Success: true, + Result: &downloadSkillResult{ + DownloadURL: "https://example.com/skill.zip", + FileName: "skill.zip", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var parsed downloadSkillResponse + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if parsed.Success != original.Success { + t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success) + } + if parsed.Result.DownloadURL != original.Result.DownloadURL { + t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL) + } + if parsed.Result.FileName != original.Result.FileName { + t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName) + } +} + +func TestSkillCommandHelp(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "--help"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := out.String() + // Check for the Long description which is shown in help + if !strings.Contains(output, "技能") { + t.Errorf("help should mention '技能', got: %s", output) + } + if !strings.Contains(output, "add") { + t.Errorf("help should mention 'add' subcommand, got: %s", output) + } +} + +func TestSkillAddCommandHelp(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "--help"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := out.String() + // Should mention supported targets + expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"} + for _, target := range expectedTargets { + if !strings.Contains(output, target) { + t.Errorf("help should mention target '%s', got: %s", target, output) + } + } +} + +func TestDownloadSkillFileSuccess(t *testing.T) { + // Create a mock server that returns a zip file + expectedContent := []byte("fake zip content") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + w.Write(expectedContent) + })) + defer server.Close() + + // Download the file + ctx := context.Background() + tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip") + if err != nil { + t.Fatalf("downloadSkillFile() error = %v", err) + } + defer os.Remove(tempPath) + + // Verify the downloaded content + content, err := os.ReadFile(tempPath) + if err != nil { + t.Fatalf("failed to read downloaded file: %v", err) + } + + if !bytes.Equal(content, expectedContent) { + t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent) + } +} + +func TestDownloadSkillFileServerError(t *testing.T) { + // Create a mock server that returns 500 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx := context.Background() + _, err := downloadSkillFile(ctx, server.URL, "test.zip") + if err == nil { + t.Error("downloadSkillFile() should fail on server error") + } +} + +func TestExtractSkillZipEmptyZip(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "empty.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create an empty zip file + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + zipWriter := zip.NewWriter(zipFile) + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract should succeed even for empty zip + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Errorf("extractSkillZip() should not fail for empty zip: %v", err) + } + + // Destination directory should be created + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Errorf("destination directory should be created") + } +} + +func TestExtractSkillZipWithDirectories(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip with directory entries + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Add a directory entry with proper permissions + header := &zip.FileHeader{ + Name: "mydir/", + Method: zip.Deflate, + } + header.SetMode(0755 | os.ModeDir) + _, err = zipWriter.CreateHeader(header) + if err != nil { + t.Fatalf("failed to create directory in zip: %v", err) + } + + // Add a file in the directory + fileHeader := &zip.FileHeader{ + Name: "mydir/file.txt", + Method: zip.Deflate, + } + fileHeader.SetMode(0644) + writer, err := zipWriter.CreateHeader(fileHeader) + if err != nil { + t.Fatalf("failed to create file in zip: %v", err) + } + if _, err := writer.Write([]byte("content")); err != nil { + t.Fatalf("failed to write content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Fatalf("extractSkillZip() error = %v", err) + } + + // Verify directory was created + dirPath := filepath.Join(destDir, "mydir") + info, err := os.Stat(dirPath) + if err != nil { + t.Errorf("directory should exist: %v", err) + } else if !info.IsDir() { + t.Errorf("mydir should be a directory") + } + + // Verify file exists + filePath := filepath.Join(destDir, "mydir", "file.txt") + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("file should exist: %v", err) + } else if string(content) != "content" { + t.Errorf("file content mismatch: got %s, want 'content'", string(content)) + } +} diff --git a/internal/app/timing.go b/internal/app/timing.go new file mode 100644 index 00000000..9f3ff5e3 --- /dev/null +++ b/internal/app/timing.go @@ -0,0 +1,177 @@ +// 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) != "" +} diff --git a/internal/app/timing_test.go b/internal/app/timing_test.go new file mode 100644 index 00000000..f57fb9e3 --- /dev/null +++ b/internal/app/timing_test.go @@ -0,0 +1,173 @@ +// 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") + } +} diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go new file mode 100644 index 00000000..95daf49f --- /dev/null +++ b/internal/app/tool_caller_adapter.go @@ -0,0 +1,104 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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 "" +} diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go new file mode 100644 index 00000000..ccee390b --- /dev/null +++ b/internal/app/upgrade.go @@ -0,0 +1,720 @@ +// 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 +} diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go new file mode 100644 index 00000000..f1c6efa2 --- /dev/null +++ b/internal/app/upgrade_test.go @@ -0,0 +1,430 @@ +// 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") + } +} diff --git a/internal/app/version.go b/internal/app/version.go index 2942c201..f93c9ebc 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -13,7 +13,22 @@ package app -var version = "v1.0.2" +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 + } +} // Version returns the current CLI version string, including build metadata // when injected via ldflags (buildTime, gitCommit). @@ -23,3 +38,12 @@ 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 } diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index 0f8fb940..2eb5b682 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) { func TestClientID_EnvFallback(t *testing.T) { SetClientID("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "env-id") if got := ClientID(); got != "env-id" { t.Fatalf("expected env-id, got %s", got) @@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) { func TestClientID_Default(t *testing.T) { SetClientID("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "") - if got := ClientID(); got != DefaultClientID { - t.Fatalf("expected default, got %s", got) + // When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string + if strings.HasPrefix(DefaultClientID, "<") { + if got := ClientID(); got != "" { + t.Fatalf("expected empty string for placeholder, got %s", got) + } + } else { + if got := ClientID(); got != DefaultClientID { + t.Fatalf("expected default, got %s", got) + } } } @@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) { func TestClientSecret_EnvFallback(t *testing.T) { SetClientSecret("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "env-secret") if got := ClientSecret(); got != "env-secret" { t.Fatalf("expected env-secret, got %s", got) @@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) { func TestClientSecret_Default(t *testing.T) { SetClientSecret("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "") if got := ClientSecret(); got != DefaultClientSecret { t.Fatalf("expected default, got %s", got) diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go new file mode 100644 index 00000000..ca076908 --- /dev/null +++ b/internal/auth/cli_auth_check_test.go @@ -0,0 +1,840 @@ +// 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) +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 94f8cc9c..8b7a0d6c 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -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" ) @@ -96,6 +96,23 @@ type serviceResult struct { } func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { + // Ensure we have a valid client ID (fetch from MCP if not available) + if p.clientID == "" { + if p.logger != nil { + p.logger.Debug("client ID not configured, fetching from MCP server") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + // Mark that clientID is from MCP + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } + } + const maxAttempts = 3 for attempt := 1; attempt <= maxAttempts; attempt++ { tokenData, err := p.loginOnce(ctx, attempt) @@ -150,6 +167,44 @@ 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) + 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) + } else if authStatus.Success && !authStatus.Result.CLIAuthEnabled { + // CLI auth is disabled - show detailed error with admin info + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) + _, _ = fmt.Fprintln(p.output(), "") + + // Try to get super admin list + admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken) + if adminErr == nil && admins.Success && len(admins.Result) > 0 { + // Show up to 3 admins + maxAdmins := 3 + if len(admins.Result) < maxAdmins { + maxAdmins = len(admins.Result) + } + var adminNames []string + for i := 0; i < maxAdmins; i++ { + adminNames = append(adminNames, admins.Result[i].Name) + } + _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、")) + } + + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。")) + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启")) + } + // Save token data with associated client ID for refresh tokenData.ClientID = p.clientID if err := SaveTokenData(p.configDir, tokenData); err != nil { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 446cec57..d56201f4 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode func TestRequestDeviceCodeSuccess(t *testing.T) { t.Parallel() + // Set a test client ID + SetClientID("test-client-id") + t.Cleanup(func() { SetClientID("") }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("method = %s, want POST", r.Method) diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index af5aa791..87dadbae 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -16,6 +16,7 @@ package auth import ( "os" "path/filepath" + "strings" "sync" ) @@ -59,16 +60,107 @@ const ( LogoutURL = "https://login.dingtalk.com/oauth2/logout" LogoutContinueURL = "https://login.dingtalk.com" + + // MCP API endpoints for CLI authorization management. + DefaultMCPBaseURL = "https://mcp.dingtalk.com" + CLIAuthEnabledPath = "/cli/cliAuthEnabled" + SuperAdminPath = "/cli/superAdmin" + SendCliAuthApplyPath = "/cli/sendCliAuthApply" + ClientIDPath = "/cli/clientId" + + // MCP OAuth endpoints (used when clientId is fetched from MCP). + MCPOAuthTokenPath = "/oauth2/getToken" + MCPRefreshTokenPath = "/oauth2/refreshToken" + MCPRevokeTokenPath = "/oauth2/revokeToken" ) +// GetMCPBaseURL returns the MCP base URL with priority: +// 1. ~/.dws/mcp_url file content (for pre-release environment) +// 2. Default value (https://mcp.dingtalk.com) +func GetMCPBaseURL() string { + mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url") + if data, err := os.ReadFile(mcpURLPath); err == nil { + if url := strings.TrimSpace(string(data)); url != "" { + return url + } + } + return DefaultMCPBaseURL +} + // Runtime overrides set via CLI flags (--client-id, --client-secret). // These take highest priority over environment variables and defaults. var ( clientMu sync.RWMutex runtimeClientID string runtimeClientSecret string + // clientIDFromMCP indicates whether the clientID was fetched from MCP server. + // When true, MCP OAuth endpoints should be used instead of direct DingTalk API. + clientIDFromMCP bool ) +// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced. +func SetClientIDFromMCP(id string) { + clientMu.Lock() + defer clientMu.Unlock() + runtimeClientID = id + clientIDFromMCP = true +} + +// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server. +func IsClientIDFromMCP() bool { + clientMu.RLock() + defer clientMu.RUnlock() + return clientIDFromMCP +} + +// GetUserAccessTokenURL returns the appropriate token exchange URL. +// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API. +func GetUserAccessTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPOAuthTokenPath + } + return UserAccessTokenURL +} + +// GetRefreshTokenURL returns the appropriate token refresh URL. +// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API. +func GetRefreshTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPRefreshTokenPath + } + return UserAccessTokenURL // DingTalk uses same endpoint for refresh +} + +// GetRevokeTokenURL returns the token revocation URL (MCP only). +// Returns empty string if not using MCP mode. +func GetRevokeTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPRevokeTokenPath + } + return "" // Direct mode doesn't have revoke endpoint +} + +// 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() @@ -87,7 +179,9 @@ func SetClientSecret(secret string) { // 1. Runtime override (CLI flag --client-id) // 2. Persisted app config (from previous login) // 3. Environment variable (DWS_CLIENT_ID) -// 4. Default hardcoded value +// 4. Default hardcoded value (if not a placeholder) +// Returns empty string if no valid client ID is available. +// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login() func ClientID() string { clientMu.RLock() override := runtimeClientID @@ -102,7 +196,11 @@ func ClientID() string { if v := os.Getenv("DWS_CLIENT_ID"); v != "" { return v } - return DefaultClientID + // Only return default if it's not a placeholder + if !strings.HasPrefix(DefaultClientID, "<") { + return DefaultClientID + } + return "" } // ClientSecret returns the OAuth client secret with priority: @@ -127,6 +225,13 @@ func ClientSecret() string { return DefaultClientSecret } +// HasValidClientSecret returns true if a valid client secret is available. +// A valid secret is one that is not a placeholder (e.g., ). +func HasValidClientSecret() bool { + secret := ClientSecret() + return secret != "" && !strings.HasPrefix(secret, "<") +} + // getRuntimeCredentials returns the runtime-override credentials if set. // Returns empty strings if no runtime overrides were provided. func getRuntimeCredentials() (clientID, clientSecret string) { diff --git a/internal/auth/filelock.go b/internal/auth/filelock.go index de266939..f5ca8ce1 100644 --- a/internal/auth/filelock.go +++ b/internal/auth/filelock.go @@ -21,7 +21,7 @@ import ( "sync" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const ( diff --git a/internal/auth/identity.go b/internal/auth/identity.go index 66f287fa..c800610d 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -25,7 +25,8 @@ import ( "os" "path/filepath" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) const identityFile = "identity.json" @@ -82,8 +83,11 @@ func (id *Identity) Headers() map[string]string { if id.Source != "" { h["x-dws-source"] = id.Source } - // Constant headers for MCP gateway tracking - h["x-dingtalk-scenario-code"] = "com.dingtalk.cli" + scenarioCode := "com.dingtalk.cli" + if sc := edition.Get().ScenarioCode; sc != "" { + scenarioCode = sc + } + h["x-dingtalk-scenario-code"] = scenarioCode h["x-dingtalk-source"] = "github" return h } diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go index 635d52a7..1ab513f6 100644 --- a/internal/auth/keychain_store.go +++ b/internal/auth/keychain_store.go @@ -105,3 +105,45 @@ 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) +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go index c74cec3e..41c6298b 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -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" diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index a5a97ce7..0a2fceab 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -23,13 +23,20 @@ import ( "net/url" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { + // Use MCP mode if clientID is from MCP server + if IsClientIDFromMCP() { + return p.exchangeCodeViaMCP(ctx, code) + } + // Direct mode with client secret + clientID := ClientID() + clientSecret := ClientSecret() body := map[string]string{ - "clientId": ClientID(), - "clientSecret": ClientSecret(), + "clientId": clientID, + "clientSecret": clientSecret, "code": code, "grantType": "authorization_code", } @@ -37,13 +44,72 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa if err != nil { return nil, err } - return p.parseTokenResponse(resp) + data, err := p.parseTokenResponse(resp) + if err != nil { + return nil, err + } + // Snapshot credentials used for this token (for refresh) + data.ClientID = clientID + data.Source = resolveCredentialSource() + // Save clientSecret for future refresh (even if env changes) + if err := SaveClientSecret(clientID, clientSecret); err != nil { + // Log warning but don't fail login + fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err) + } + return data, nil +} + +// exchangeCodeViaMCP exchanges auth code for token via MCP proxy. +// This is used when client secret is not available (server-side secret management). +func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { + clientID := ClientID() + url := GetMCPBaseURL() + MCPOAuthTokenPath + body := map[string]string{ + "clientId": clientID, + "authCode": code, + "grantType": "authorization_code", + } + resp, err := p.postJSON(ctx, url, body) + if err != nil { + return nil, err + } + data, err := p.parseMCPTokenResponse(resp) + if err != nil { + return nil, err + } + // Snapshot credentials used for this token (for refresh) + data.ClientID = clientID + data.Source = "mcp" + // MCP mode doesn't need to save clientSecret (server-side managed) + return data, nil } func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) { + // Use stored Source to determine refresh path (not current runtime state) + // This ensures refresh works even if environment variables changed since login + if data.Source == "mcp" { + return p.refreshViaMCP(ctx, data) + } + + // Direct mode: use stored clientId and load saved clientSecret + clientID := data.ClientID + if clientID == "" { + // Fallback for legacy tokens without stored clientId + clientID = ClientID() + } + clientSecret := LoadClientSecret(clientID) + if clientSecret == "" { + // Fallback: try current environment + clientSecret = ClientSecret() + } + + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录") + } + body := map[string]string{ - "clientId": ClientID(), - "clientSecret": ClientSecret(), + "clientId": clientID, + "clientSecret": clientSecret, "refreshToken": data.RefreshToken, "grantType": "refresh_token", } @@ -55,6 +121,51 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token if err != nil { return nil, err } + // Preserve original credentials info + updated.ClientID = data.ClientID + updated.Source = data.Source + updated.PersistentCode = data.PersistentCode + updated.CorpID = data.CorpID + updated.UserID = data.UserID + updated.UserName = data.UserName + updated.CorpName = data.CorpName + + if err := SaveTokenData(p.configDir, updated); err != nil { + return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) + } + return updated, nil +} + +// refreshViaMCP refreshes token via MCP proxy. +func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) { + // Use stored clientId from token data + clientID := data.ClientID + if clientID == "" { + // Fallback for legacy tokens + clientID = ClientID() + } + + if clientID == "" { + return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录") + } + + url := GetMCPBaseURL() + MCPRefreshTokenPath + body := map[string]string{ + "clientId": clientID, + "refreshToken": data.RefreshToken, + "grantType": "refresh_token", + } + resp, err := p.postJSON(ctx, url, body) + if err != nil { + return nil, err + } + updated, err := p.parseMCPTokenResponse(resp) + if err != nil { + return nil, err + } + // Preserve original credentials info + updated.ClientID = data.ClientID + updated.Source = data.Source updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID @@ -132,6 +243,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) { return data, nil } +// parseMCPTokenResponse parses token response from MCP proxy. +// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."} +func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { + var resp struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + PersistentCode string `json:"persistentCode"` + ExpiresIn int64 `json:"expiresIn"` + CorpID string `json:"corpId"` + // Error fields (when request fails) + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body)) + } + // Check for error response + if resp.ErrorCode != "" || resp.ErrorMsg != "" { + return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg) + } + if resp.AccessToken == "" { + return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body)) + } + + now := time.Now() + expiresIn := resp.ExpiresIn + if expiresIn <= 0 { + expiresIn = config.DefaultAccessTokenExpiry + } + data := &TokenData{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second), + RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime), + CorpID: resp.CorpID, + } + if resp.PersistentCode != "" { + data.PersistentCode = resp.PersistentCode + } + return data, nil +} + func buildAuthURL(clientID, redirectURI string) string { params := url.Values{ "client_id": {clientID}, @@ -143,9 +296,866 @@ func buildAuthURL(clientID, redirectURI string) string { return AuthorizeURL + "?" + params.Encode() } -const successHTML = ` -钉钉 CLI - -

✅ 授权成功

请返回终端继续操作。此页面可以关闭。

` +const successHTML = ` + + + + 钉钉 CLI + + + +
+ lock icon +

授权成功

+

请返回终端继续操作。此页面可以关闭。

+
+ +` + +const notEnabledHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

该组织尚未开启 CLI 数据访问权限

+

+ 你所选择的组织管理员尚未开启
「允许成员通过 CLI + 访问其个人数据」的权限。 +

+ +
+ +
+
+ +
    + +
    +
    +
    +
    + +
    + + + + 已向管理员发送权限申请,正在等待审核
    审核通过后,请返回终端继续操作
    +
    + + + 返回选择其他组织 +
    + + + +` + +// CLIAuthStatus represents the response from /cli/cliAuthEnabled API. +type CLIAuthStatus struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + } `json:"result"` +} + +// SuperAdmin represents a corp super admin. +type SuperAdmin struct { + StaffID string `json:"staffId"` + Name string `json:"name"` +} + +// SuperAdminResponse represents the response from /cli/superAdmin API. +type SuperAdminResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result []SuperAdmin `json:"result"` +} + +// SendApplyResponse represents the response from /cli/sendCliAuthApply API. +type SendApplyResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result bool `json:"result"` +} + +// mcpRequestMaxRetries is the maximum number of attempts for MCP API calls +// (e.g. /cli/cliAuthEnabled, /cli/clientId, /cli/superAdmin, /cli/sendCliAuthApply) +// to tolerate transient network errors before propagating the failure. +const mcpRequestMaxRetries = 3 + +// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp. +// It retries up to mcpRequestMaxRetries times on transient errors to avoid +// false negatives caused by momentary network issues. +func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) + if err == nil { + return status, nil + } + lastErr = err + } + return nil, fmt.Errorf("check CLI auth status failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { + url := GetMCPBaseURL() + CLIAuthEnabledPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + client := p.httpClient + if client == nil { + client = oauthHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var status CLIAuthStatus + if err := json.Unmarshal(data, &status); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &status, nil +} + +// GetSuperAdmins fetches the list of corp super admins. +// It retries up to mcpRequestMaxRetries times on transient errors. +func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + result, err := doGetSuperAdmins(ctx, accessToken) + if err == nil { + return result, nil + } + lastErr = err + } + return nil, fmt.Errorf("get super admins failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + url := GetMCPBaseURL() + SuperAdminPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var result SuperAdminResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} + +// SendCliAuthApply sends a CLI auth apply request to the specified admin. +// It retries up to mcpRequestMaxRetries times on transient errors. +func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) + if err == nil { + return result, nil + } + lastErr = err + } + return nil, fmt.Errorf("send CLI auth apply failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var result SendApplyResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} + +// ClientIDResponse represents the response from /cli/clientId API. +type ClientIDResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result string `json:"result"` +} + +// FetchClientIDFromMCP fetches the CLI client ID from MCP server. +// This is used when no client ID is provided via flags, config, or env vars. +// It retries up to mcpRequestMaxRetries times on transient errors. +func FetchClientIDFromMCP(ctx context.Context) (string, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + id, err := doFetchClientIDFromMCP(ctx) + if err == nil { + return id, nil + } + lastErr = err + } + return "", fmt.Errorf("fetch client ID failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doFetchClientIDFromMCP(ctx context.Context) (string, error) { + url := GetMCPBaseURL() + ClientIDPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + var result ClientIDResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("parsing response: %w", err) + } + if !result.Success { + return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg) + } + return result.Result, nil +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 02dffe6f..071f9237 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -15,6 +15,7 @@ package auth import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -22,6 +23,7 @@ import ( "net" "net/http" "os" + "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" @@ -92,6 +94,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Fall through: full browser OAuth flow. + // Ensure we have a valid client ID (fetch from MCP if not available) + if p.clientID == "" { + if p.logger != nil { + p.logger.Debug("client ID not configured, fetching from MCP server") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + // Mark that clientID is from MCP, so we use MCP OAuth endpoints + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } + } + // Find a free port for the callback server. listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -100,15 +119,79 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath) - codeCh := make(chan string, 1) + // Channel to pass callback result (token data or error with CLI auth status) + type callbackResult struct { + token *TokenData + err error + cliAuthDisabled bool + } + resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) + // Shared state for API handlers (protected by mutex) + var ( + callbackToken *TokenData + callbackProcessedCode string // The auth code that has been successfully processed + 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 + ) + mux := http.NewServeMux() mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) { + // Get code first to check if this is a new authorization or page refresh code := r.URL.Query().Get("authCode") if code == "" { code = r.URL.Query().Get("code") } + + // Check state and handle page refresh or concurrent requests + 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() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if processedAuthDisabled { + _, _ = 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, `

    正在处理授权,请稍候...

    `) + 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 + } + callbackTokenMu.Unlock() + if code == "" { select { case errCh <- errors.New(i18n.T("回调中未收到授权码")): @@ -118,24 +201,151 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码")) return } - // Write response FIRST before notifying main goroutine - // This prevents race condition where server.Shutdown is called - // before response is fully sent + + // Exchange code for token + tokenData, exchangeErr := p.exchangeCode(ctx, code) + if exchangeErr != nil { + // Clear in-progress state on error + callbackTokenMu.Lock() + if callbackCodeInProgress == code { + callbackCodeInProgress = "" + } + callbackTokenMu.Unlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, "

    授权失败

    %s

    ", exchangeErr.Error()) + select { + case resultCh <- callbackResult{err: exchangeErr}: + default: + } + 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) + authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + + // Update CLI auth disabled state + callbackTokenMu.Lock() + callbackAuthDisabled = !cliAuthEnabled + callbackTokenMu.Unlock() + + // Display appropriate HTML based on CLI auth status w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprint(w, successHTML) + if !cliAuthEnabled { + _, _ = fmt.Fprint(w, notEnabledHTML) + } else { + _, _ = fmt.Fprint(w, successHTML) + } // Ensure response is flushed to client if f, ok := w.(http.Flusher); ok { f.Flush() } - // Now notify main goroutine (non-blocking) + // Notify main goroutine with full result select { - case codeCh <- code: - // Success + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}: default: - // Select already exited (timeout/cancel); code discarded but response already sent } }) + // API endpoint: get super admins + mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := GetSuperAdmins(ctx, token.AccessToken) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // API endpoint: send CLI auth apply + mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + adminStaffID := r.URL.Query().Get("adminStaffId") + if adminStaffID == "" { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`)) + return + } + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + // Mark apply as sent and save selected admin on success + if result.Success && result.Result { + callbackTokenMu.Lock() + callbackApplySent = true + callbackSelectedAdminId = adminStaffID + callbackTokenMu.Unlock() + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // API endpoint: get current status (clientId, applySent, selectedAdminId) + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + applySent := callbackApplySent + selectedAdminId := callbackSelectedAdminId + callbackTokenMu.Unlock() + _, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId) + }) + + // API endpoint: check CLI auth enabled status + mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // Success page endpoint + mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprint(w, successHTML) + }) + server := &http.Server{Handler: mux} go func() { if serveErr := server.Serve(listener); !errors.Is(serveErr, http.ErrServerClosed) { @@ -169,9 +379,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro timeout := time.NewTimer(5 * time.Minute) defer timeout.Stop() - var authCode string + var result callbackResult select { - case authCode = <-codeCh: + case result = <-resultCh: case err := <-errCh: return nil, err case <-timeout.C: @@ -180,11 +390,73 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro return nil, ctx.Err() } - tokenData, err := p.exchangeCode(ctx, authCode) - if err != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) + // Handle callback errors + if result.err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err) } + // Handle CLI auth disabled - keep server running for user to apply + if result.cliAuthDisabled { + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...")) + + // Poll for CLI auth status while waiting + applyTimeout := time.NewTimer(10 * time.Minute) + defer applyTimeout.Stop() + pollTicker := time.NewTicker(5 * time.Second) + defer pollTicker.Stop() + + elapsedSeconds := 0 + for { + select { + case <-applyTimeout.C: + return nil, errors.New(i18n.T("操作超时,请重新登录")) + case <-ctx.Done(): + return nil, ctx.Err() + case <-pollTicker.C: + elapsedSeconds += 5 + + // Get latest token and state (user may have switched org) + callbackTokenMu.Lock() + currentToken := callbackToken + currentAuthDisabled := callbackAuthDisabled + applySent := callbackApplySent + callbackTokenMu.Unlock() + + // Check if user switched to an org with CLI auth enabled + if currentToken != nil && !currentAuthDisabled { + _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) + time.Sleep(2 * time.Second) + result.token = currentToken + result.cliAuthDisabled = false + goto continueLogin + } + + // Check if CLI auth is now enabled (admin approved) + if currentToken != nil { + authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken) + if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled { + _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) + time.Sleep(2 * time.Second) + result.token = currentToken + result.cliAuthDisabled = false + goto continueLogin + } + } + + // Show polling status based on apply state + if applySent { + _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds) + } else { + _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds) + } + } + } + } + +continueLogin: + tokenData := result.token + // Save token data with associated client ID for refresh tokenData.ClientID = p.clientID if err := SaveTokenData(p.configDir, tokenData); err != nil { diff --git a/internal/auth/secure_store.go b/internal/auth/secure_store.go index addecc33..89af75ab 100644 --- a/internal/auth/secure_store.go +++ b/internal/auth/secure_store.go @@ -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" diff --git a/internal/auth/token.go b/internal/auth/token.go index 644eb638..4b7f9472 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -14,7 +14,9 @@ package auth import ( + "bytes" "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -103,10 +105,16 @@ func DeleteTokenData(configDir string) error { return legacyErr } -// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token. +// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token. +// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout. // This should be called before deleting local token data. // The function is best-effort: errors are returned but callers may choose to ignore them. func RevokeTokenRemote(ctx context.Context) error { + // Use MCP revoke endpoint when clientID is from MCP + if IsClientIDFromMCP() { + return revokeTokenViaMCP(ctx) + } + // Direct mode: use DingTalk logout endpoint logoutURL, err := url.Parse(LogoutURL) if err != nil { return fmt.Errorf("parsing logout URL: %w", err) @@ -143,3 +151,45 @@ func RevokeTokenRemote(ctx context.Context) error { return nil } + +// revokeTokenViaMCP revokes token via MCP endpoint. +func revokeTokenViaMCP(ctx context.Context) error { + revokeURL := GetRevokeTokenURL() + if revokeURL == "" { + return nil // No revoke endpoint available + } + + // Load current token to get accessToken + tokenData, err := LoadTokenData(getDefaultConfigDir()) + if err != nil || tokenData == nil { + return nil // No token to revoke + } + + body := map[string]string{ + "clientId": ClientID(), + "accessToken": tokenData.AccessToken, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling revoke request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating revoke request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("calling revoke endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 01e1fc41..60caea9b 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -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" ) diff --git a/internal/cli/loader.go b/internal/cli/loader.go index c05439b8..085a3c78 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -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 ( diff --git a/internal/compat/registry.go b/internal/compat/registry.go index cf6a40e7..3d47da67 100644 --- a/internal/compat/registry.go +++ b/internal/compat/registry.go @@ -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" ) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 9ba0a6a0..38e90ac2 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -253,6 +253,12 @@ func PrintJSON(w io.Writer, err error) error { } if typed.ServerDiag.ServerErrorCode != "" { errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode + // Add user-friendly hint for specific server error codes + switch typed.ServerDiag.ServerErrorCode { + case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": + errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。" + errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings" + } } if typed.ServerDiag.TechnicalDetail != "" { errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail @@ -312,6 +318,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if typed.Hint != "" { lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint)) } + + // Add user-friendly hint for specific server error codes + switch typed.ServerDiag.ServerErrorCode { + case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": + lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。") + lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings") + } + if len(typed.Actions) > 0 { for _, action := range typed.Actions { if strings.TrimSpace(action) == "" { diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 0489bc54..c315037d 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -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" ) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index d42d5e25..5d578e40 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -14,15 +14,14 @@ 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" ) @@ -88,7 +87,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - title := flagOrFallback(cmd, "title", "subject", "content") + title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content") if strings.TrimSpace(title) == "" { return apperrors.NewValidation("--title is required") } @@ -103,7 +102,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command { "executorIds": executorIds, } if v, _ := cmd.Flags().GetString("due"); v != "" { - ms, err := parseISOTimeToMillis("due", v) + ms, err := cmdutil.ParseISOTimeToMillis("due", v) if err != nil { return err } @@ -270,7 +269,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command { inner["subject"] = v } if v, _ := cmd.Flags().GetString("due"); v != "" { - ms, err := parseISOTimeToMillis("due", v) + ms, err := cmdutil.ParseISOTimeToMillis("due", v) if err != nil { return err } @@ -444,16 +443,6 @@ 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) @@ -470,25 +459,6 @@ 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 { diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 916f03ce..bfd36759 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -155,5 +155,20 @@ "返回数据缺少 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" } diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 2a029280..dbfe9e9b 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -155,5 +155,20 @@ "返回数据缺少 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 授权状态...", "🔐 登录钉钉": "🔐 登录钉钉" } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 3f3dd0a6..a4c5b459 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -24,7 +24,7 @@ import ( "strconv" "sync" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const ( diff --git a/internal/market/registry.go b/internal/market/registry.go index 070b622e..8b4ab679 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -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 ( diff --git a/internal/output/sanitize.go b/internal/output/sanitize.go index 0d20df5d..95f333dd 100644 --- a/internal/output/sanitize.go +++ b/internal/output/sanitize.go @@ -13,7 +13,7 @@ package output -import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate" +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate" // SanitizeForTerminal strips ANSI escape sequences, control characters, and // dangerous Unicode from text before it is printed to a terminal. diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go index 1cd4207a..21378917 100644 --- a/internal/pipeline/handlers/paramname.go +++ b/internal/pipeline/handlers/paramname.go @@ -17,6 +17,7 @@ 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 @@ -101,7 +102,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri ambiguous := false for _, candidate := range candidates { - dist := levenshtein(bare, candidate) + dist := cmdutil.LevenshteinDist(bare, candidate) if dist < bestDist { bestDist = dist bestMatch = candidate @@ -117,48 +118,3 @@ 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] -} diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go index ef3f6c07..b569b14b 100644 --- a/internal/pipeline/handlers/paramname_test.go +++ b/internal/pipeline/handlers/paramname_test.go @@ -18,6 +18,7 @@ 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) { @@ -40,12 +41,11 @@ func TestLevenshtein(t *testing.T) { for _, tt := range tests { t.Run(tt.a+"→"+tt.b, func(t *testing.T) { - got := levenshtein(tt.a, tt.b) + got := cmdutil.LevenshteinDist(tt.a, tt.b) if got != tt.want { - t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) } - // Verify symmetry. - gotRev := levenshtein(tt.b, tt.a) + gotRev := cmdutil.LevenshteinDist(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) } diff --git a/internal/security/storage.go b/internal/security/storage.go index 43269e80..bd066720 100644 --- a/internal/security/storage.go +++ b/internal/security/storage.go @@ -20,7 +20,7 @@ import ( "path/filepath" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) // DataFileName is the encrypted token data file name. diff --git a/internal/transport/client.go b/internal/transport/client.go index 072434a3..9b566fd7 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -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/internal/validate" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate" ) const ( diff --git a/internal/upgrade/downloader.go b/internal/upgrade/downloader.go new file mode 100644 index 00000000..38584429 --- /dev/null +++ b/internal/upgrade/downloader.go @@ -0,0 +1,185 @@ +// 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< 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 +} diff --git a/internal/upgrade/downloader_test.go b/internal/upgrade/downloader_test.go new file mode 100644 index 00000000..6a8cf0a2 --- /dev/null +++ b/internal/upgrade/downloader_test.go @@ -0,0 +1,174 @@ +// 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) + } + } +} diff --git a/internal/upgrade/github.go b/internal/upgrade/github.go new file mode 100644 index 00000000..cccff7d7 --- /dev/null +++ b/internal/upgrade/github.go @@ -0,0 +1,293 @@ +// 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) +} diff --git a/internal/upgrade/github_test.go b/internal/upgrade/github_test.go new file mode 100644 index 00000000..a50fff96 --- /dev/null +++ b/internal/upgrade/github_test.go @@ -0,0 +1,468 @@ +// 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 +} diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go new file mode 100644 index 00000000..9bbe0678 --- /dev/null +++ b/internal/upgrade/paths.go @@ -0,0 +1,249 @@ +// 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 +} diff --git a/internal/upgrade/paths_test.go b/internal/upgrade/paths_test.go new file mode 100644 index 00000000..b2088f71 --- /dev/null +++ b/internal/upgrade/paths_test.go @@ -0,0 +1,252 @@ +// 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]) + } +} diff --git a/internal/upgrade/replacer.go b/internal/upgrade/replacer.go new file mode 100644 index 00000000..c4fd25fd --- /dev/null +++ b/internal/upgrade/replacer.go @@ -0,0 +1,203 @@ +// 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() +} diff --git a/internal/upgrade/replacer_test.go b/internal/upgrade/replacer_test.go new file mode 100644 index 00000000..96764504 --- /dev/null +++ b/internal/upgrade/replacer_test.go @@ -0,0 +1,264 @@ +// 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) + } +} diff --git a/internal/upgrade/rollback.go b/internal/upgrade/rollback.go new file mode 100644 index 00000000..9431b09b --- /dev/null +++ b/internal/upgrade/rollback.go @@ -0,0 +1,299 @@ +// 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 +} diff --git a/internal/upgrade/rollback_test.go b/internal/upgrade/rollback_test.go new file mode 100644 index 00000000..74c34b43 --- /dev/null +++ b/internal/upgrade/rollback_test.go @@ -0,0 +1,342 @@ +// 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 +} diff --git a/internal/upgrade/verify.go b/internal/upgrade/verify.go new file mode 100644 index 00000000..6390e39d --- /dev/null +++ b/internal/upgrade/verify.go @@ -0,0 +1,66 @@ +// 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) +} diff --git a/internal/upgrade/verify_test.go b/internal/upgrade/verify_test.go new file mode 100644 index 00000000..8e8e9851 --- /dev/null +++ b/internal/upgrade/verify_test.go @@ -0,0 +1,85 @@ +// 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") + } +} diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go new file mode 100644 index 00000000..deb0f1f9 --- /dev/null +++ b/internal/upgrade/version.go @@ -0,0 +1,47 @@ +// 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 +} diff --git a/internal/upgrade/version_test.go b/internal/upgrade/version_test.go new file mode 100644 index 00000000..c39cd691 --- /dev/null +++ b/internal/upgrade/version_test.go @@ -0,0 +1,51 @@ +// 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) + } + } +} diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go new file mode 100644 index 00000000..dd2f44f9 --- /dev/null +++ b/pkg/cli/cli.go @@ -0,0 +1,30 @@ +// 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() +} diff --git a/pkg/cmdutil/flagfix.go b/pkg/cmdutil/flagfix.go new file mode 100644 index 00000000..610937a9 --- /dev/null +++ b/pkg/cmdutil/flagfix.go @@ -0,0 +1,166 @@ +// 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] +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go new file mode 100644 index 00000000..45e54027 --- /dev/null +++ b/pkg/cmdutil/flags.go @@ -0,0 +1,153 @@ +// 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 +} diff --git a/pkg/cmdutil/time.go b/pkg/cmdutil/time.go new file mode 100644 index 00000000..90f7e4e7 --- /dev/null +++ b/pkg/cmdutil/time.go @@ -0,0 +1,67 @@ +// 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 +} diff --git a/internal/config/constants.go b/pkg/config/constants.go similarity index 100% rename from internal/config/constants.go rename to pkg/config/constants.go diff --git a/internal/config/constants_test.go b/pkg/config/constants_test.go similarity index 100% rename from internal/config/constants_test.go rename to pkg/config/constants_test.go diff --git a/internal/convert/slices.go b/pkg/convert/slices.go similarity index 100% rename from internal/convert/slices.go rename to pkg/convert/slices.go diff --git a/internal/convert/slices_test.go b/pkg/convert/slices_test.go similarity index 100% rename from internal/convert/slices_test.go rename to pkg/convert/slices_test.go diff --git a/pkg/edition/default.go b/pkg/edition/default.go new file mode 100644 index 00000000..682d9686 --- /dev/null +++ b/pkg/edition/default.go @@ -0,0 +1,23 @@ +// 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", + } +} diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go new file mode 100644 index 00000000..54466514 --- /dev/null +++ b/pkg/edition/edition.go @@ -0,0 +1,106 @@ +// 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 +} diff --git a/pkg/editiontest/contract.go b/pkg/editiontest/contract.go new file mode 100644 index 00000000..cc43f11d --- /dev/null +++ b/pkg/editiontest/contract.go @@ -0,0 +1,88 @@ +// 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") + } + }) +} diff --git a/pkg/editiontest/contract_test.go b/pkg/editiontest/contract_test.go new file mode 100644 index 00000000..2ec86a0c --- /dev/null +++ b/pkg/editiontest/contract_test.go @@ -0,0 +1,25 @@ +// 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()) +} diff --git a/internal/validate/input.go b/pkg/validate/input.go similarity index 100% rename from internal/validate/input.go rename to pkg/validate/input.go diff --git a/internal/validate/input_test.go b/pkg/validate/input_test.go similarity index 100% rename from internal/validate/input_test.go rename to pkg/validate/input_test.go diff --git a/internal/validate/resource.go b/pkg/validate/resource.go similarity index 100% rename from internal/validate/resource.go rename to pkg/validate/resource.go diff --git a/internal/validate/resource_test.go b/pkg/validate/resource_test.go similarity index 100% rename from internal/validate/resource_test.go rename to pkg/validate/resource_test.go diff --git a/internal/validate/sanitize.go b/pkg/validate/sanitize.go similarity index 100% rename from internal/validate/sanitize.go rename to pkg/validate/sanitize.go diff --git a/internal/validate/sanitize_test.go b/pkg/validate/sanitize_test.go similarity index 100% rename from internal/validate/sanitize_test.go rename to pkg/validate/sanitize_test.go diff --git a/scripts/dev/test-release.sh b/scripts/dev/test-release.sh new file mode 100644 index 00000000..1bd3ae08 --- /dev/null +++ b/scripts/dev/test-release.sh @@ -0,0 +1,26 @@ +#!/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!" \ No newline at end of file diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh index 57a6a88a..6254ae29 100755 --- a/scripts/release/post-goreleaser.sh +++ b/scripts/release/post-goreleaser.sh @@ -53,14 +53,23 @@ detect_arch() { } resolve_version() { + # Priority 1: Use DWS_PACKAGE_VERSION environment variable (set by CI) if [ -n "$PACKAGE_VERSION" ]; then - printf '%s\n' "$PACKAGE_VERSION" + # Strip leading 'v' if present for semver compatibility + printf '%s\n' "$PACKAGE_VERSION" | sed 's/^v//' 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" ]; then - err "could not resolve package version from internal/app/version.go" + if [ -z "$version_line" ] || [ "$version_line" = "dev" ]; then + err "could not resolve package version - set DWS_PACKAGE_VERSION or create a git tag" fi printf '%s\n' "$version_line" } diff --git a/test/cli/root_command_test.go b/test/cli/root_command_test.go index 52f77304..ad4632c5 100644 --- a/test/cli/root_command_test.go +++ b/test/cli/root_command_test.go @@ -32,16 +32,21 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) { } } -func TestSkillCommandIsNotRegisteredInPublicOSSBuild(t *testing.T) { +func TestSkillCommandIsRegistered(t *testing.T) { t.Parallel() root := app.NewRootCommand() + var found bool for _, cmd := range root.Commands() { if cmd.Name() == "skill" { - t.Fatalf("skill command should not be registered in OSS build") + found = true + break } } + if !found { + t.Fatalf("skill command should be registered") + } } // TestSchemaJSONBootstrapOutput was removed diff --git a/test/unit/open_source_policy_test.go b/test/unit/open_source_policy_test.go index e3507f9a..b6c3ab1d 100644 --- a/test/unit/open_source_policy_test.go +++ b/test/unit/open_source_policy_test.go @@ -30,6 +30,11 @@ 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 @@ -39,7 +44,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) { } if d.IsDir() { switch d.Name() { - case ".git", ".worktrees", "node_modules", "dist": + case ".git", ".worktrees", "node_modules", "dist", "plans": return filepath.SkipDir } return nil