Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
238f4256d3 | ||
|
|
b83e6dc239 | ||
|
|
a842560d71 | ||
|
|
d808843f75 | ||
|
|
6623a6969d | ||
|
|
a32d7985e6 | ||
|
|
d3f8e9d712 | ||
|
|
043881e0e3 | ||
|
|
b63e1b4469 | ||
|
|
bba94c0092 | ||
|
|
fe4a79283c | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 | ||
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e | ||
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf |
@@ -1,103 +0,0 @@
|
||||
name: Auto Dev Release
|
||||
|
||||
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
|
||||
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
|
||||
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
|
||||
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
|
||||
# - 不含 npm publish,所以自动发布天然是绿的。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/dws-devapp
|
||||
|
||||
# 串行:两次 push 不会同时算版本号撞车
|
||||
concurrency:
|
||||
group: auto-dev-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
# ---- 质量门:测试不过就不发 ----
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
# ---- 算下一个 dev 版本号并打 tag ----
|
||||
- name: Compute next dev version
|
||||
id: ver
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LATEST" ]; then
|
||||
BASE="v1.0.39"; N=0
|
||||
else
|
||||
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
|
||||
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
|
||||
fi
|
||||
NEW="${BASE}-dev.$((N + 1))"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "next dev release: $NEW (prev: ${LATEST:-none})"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$V"
|
||||
git push origin "$V"
|
||||
|
||||
# ---- 自己发 release(不靠 tag 触发 release.yml)----
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,14 +35,53 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Multi Profile E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: multi-profile-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
|
||||
|
||||
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 isolated multi-profile chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload debug artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: multi-profile-e2e-debug
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -12,7 +12,10 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -28,6 +31,9 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -78,6 +84,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -86,10 +93,18 @@ jobs:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
- name: Publish stable to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
# 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
@@ -42,3 +42,5 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
+124
@@ -6,6 +6,130 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.48] - 2026-07-07
|
||||
|
||||
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Remove-discovery delivery is now formal/stable** — the beta validation line is ready to cut as `v1.0.48`; normal stable channels (`dws upgrade`, GitHub `releases/latest`, install scripts, and npm `latest`) should receive this release after the official tag is published.
|
||||
- **Static endpoint runtime sealed for stable delivery** — the open edition no longer depends on dynamic service discovery at runtime, while preserving legacy command compatibility aliases and the synced help/skill surface from the beta.
|
||||
- **`contact label` is restored as real wukong-compatible functionality** — `dws contact label list/get/list-members` now call `get_org_labels`, `search_label_by_name`, and `get_label_members_by_labelId`; `contact role` remains an alias, and the common top-level compatibility entries (`contact search/find/list/get/self/me/whoami/get-self`) now dispatch to real user/dept/label tools where unambiguous.
|
||||
- **Skill docs match the sealed command surface** — contact docs again describe the real `contact label` three-step role lookup flow; video-conference start/invite/share flows remain explicitly unsupported and point users to the DingTalk client.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`calendar event list --dry-run` no longer executes the real list call** — the sorted event-list wrapper now respects dry-run and prints the `list_calendar_events` preview instead of calling the backend.
|
||||
- **`chat file upload` is downlined** — the hidden compatibility entry now returns a clear downline message and never calls `chat/upload_conversation_file_by_url`; the supported file path remains `chat message send --msg-type file --file-path`.
|
||||
- **Optional plugin version validation no longer pollutes every command** — incompatible local plugins such as conference are skipped at debug level during command-tree construction instead of printing a WARN on unrelated commands.
|
||||
- **PR #45 review follow-ups are folded into the release** — doc version rollback pagination now unwraps nested result/content/data envelopes for `nextCursor`, mail helper scripts handle `{result:{emailAccounts:[...]}}`, and the generated attendance `.xlsx` fixture is removed from the skill scripts.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
|
||||
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
|
||||
|
||||
## [1.0.47] - 2026-07-05
|
||||
|
||||
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
|
||||
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
|
||||
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
|
||||
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
|
||||
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
|
||||
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
|
||||
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
|
||||
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
|
||||
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
|
||||
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
|
||||
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
|
||||
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
|
||||
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
|
||||
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
|
||||
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
|
||||
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
|
||||
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
|
||||
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
|
||||
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
|
||||
|
||||
## [1.0.46] - 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
|
||||
|
||||
## [1.0.45] - 2026-06-29
|
||||
|
||||
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
|
||||
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
|
||||
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
|
||||
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
|
||||
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
|
||||
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
|
||||
|
||||
## [1.0.44] - 2026-06-28
|
||||
|
||||
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
|
||||
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
|
||||
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
|
||||
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
|
||||
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
|
||||
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
|
||||
|
||||
## [1.0.43] - 2026-06-26
|
||||
|
||||
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
|
||||
|
||||
### Added
|
||||
|
||||
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
|
||||
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
|
||||
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
|
||||
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
|
||||
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
|
||||
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
|
||||
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
|
||||
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
|
||||
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
|
||||
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
|
||||
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
|
||||
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
@@ -109,6 +109,10 @@ go build -o dws ./cmd # build to current directory
|
||||
cp dws ~/.local/bin/ # install to PATH
|
||||
```
|
||||
|
||||
Static endpoint data is generated from the Wukong baseline and committed in this
|
||||
repository under `internal/syncdata`, so source builds do not require a sibling
|
||||
data checkout.
|
||||
|
||||
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
|
||||
|
||||
</details>
|
||||
@@ -135,6 +139,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -144,12 +156,18 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
|
||||
```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 --list # list stable release versions
|
||||
dws upgrade --beta # upgrade to the latest beta pre-release
|
||||
dws upgrade --check --beta # check the beta track without installing
|
||||
dws upgrade --list --beta # list beta pre-release versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
@@ -163,8 +181,9 @@ A backup of the current version is automatically created before each upgrade. Us
|
||||
| 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`) |
|
||||
| `--list` | List available stable release versions with changelogs |
|
||||
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
@@ -225,6 +244,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
|
||||
|
||||
```bash
|
||||
dws auth login # log in to another org → adds a profile (first login becomes the primary)
|
||||
dws profile list # list logged-in orgs (primary / current marker, status)
|
||||
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
|
||||
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
|
||||
```
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
|
||||
|
||||
@@ -276,21 +311,18 @@ dws contact user search --query "engineering" --dry-run
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### Schema Discovery
|
||||
### Command Help and Schema
|
||||
|
||||
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
|
||||
Product commands are compiled into the binary in static endpoint mode. Use `--help` and the bundled Agent Skills as the source of truth; `dws schema` is retained for helper-only schemas such as `dev.*`.
|
||||
|
||||
```bash
|
||||
# Step 1: Discover all available products
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
# Inspect the current compiled command surface
|
||||
dws aitable record query --help
|
||||
|
||||
# Step 2: Inspect target tool's parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
# Helper-only schema introspection
|
||||
dws schema "dev app create"
|
||||
|
||||
# Optional: inspect DingTalk authorization metadata for PAT planning
|
||||
dws schema aitable.query_records --jq '.tool.auth'
|
||||
|
||||
# Step 3: Construct the correct call
|
||||
# Construct the call
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
@@ -309,6 +341,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
@@ -453,7 +487,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -462,14 +496,12 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
|
||||
<summary><strong>Schema Introspection</strong> — helper-only schemas in static endpoint mode</summary>
|
||||
|
||||
```bash
|
||||
dws schema # list all products and tools
|
||||
dws schema aitable.query_records # view parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # view required fields
|
||||
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
dws schema # static endpoint mode note
|
||||
dws schema "dev app create" # view helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # view required fields
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -500,9 +532,14 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
dws dev connect --channel auto --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
|
||||
> so the secret never appears in argv (`ps` / journald / shell history). The
|
||||
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
|
||||
> the CLI will warn you.
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
@@ -514,29 +551,29 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
|
||||
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
|
||||
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
|
||||
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates |
|
||||
| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
|
||||
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
|
||||
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
|
||||
| Todo | `todo` | Create / list / update / complete tasks and comments |
|
||||
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
|
||||
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
|
||||
| Ding | `ding` | Send / recall DING messages |
|
||||
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
|
||||
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
|
||||
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
|
||||
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
|
||||
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
|
||||
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
|
||||
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
|
||||
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
|
||||
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
|
||||
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
|
||||
| Live | `live` | List my live streams |
|
||||
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
|
||||
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
@@ -595,7 +632,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
|
||||
+72
-39
@@ -110,6 +110,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
```
|
||||
|
||||
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
|
||||
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -135,6 +136,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -144,12 +153,18 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --list # 列出正式 release 版本
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
|
||||
dws upgrade --list --beta # 列出 beta 预发布版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
@@ -163,8 +178,9 @@ dws upgrade -y # 跳过确认直接升级
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--list` | 列出正式 release 版本及更新日志 |
|
||||
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
@@ -225,6 +241,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
|
||||
|
||||
```bash
|
||||
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
|
||||
dws profile list # 列出已登录组织(主 / 当前标记、状态)
|
||||
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
|
||||
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
|
||||
```
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
|
||||
|
||||
@@ -276,18 +308,18 @@ dws contact user search --query "张三" --dry-run
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### Schema 发现
|
||||
### 命令帮助与 Schema
|
||||
|
||||
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
|
||||
产品命令在静态端点模式下已经编译进二进制。Agent 以 `--help` 和内置 Skill 为事实源;`dws schema` 仅保留给 `dev.*` 等 helper-only schema 查询。
|
||||
|
||||
```bash
|
||||
# 第一步:发现所有可用产品
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
# 查看当前编译出的命令面
|
||||
dws aitable record query --help
|
||||
|
||||
# 第二步:查看目标工具的参数结构
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
# helper-only schema 自省
|
||||
dws schema "dev app create"
|
||||
|
||||
# 第三步:构造正确的调用
|
||||
# 构造正确的调用
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
@@ -306,6 +338,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
@@ -450,7 +484,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -459,13 +493,12 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
|
||||
<summary><strong>Schema 自省</strong> — 静态端点模式下的 helper-only schema</summary>
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品和工具
|
||||
dws schema aitable.query_records # 查看参数 Schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
|
||||
dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
dws schema # 静态端点模式提示
|
||||
dws schema "dev app create" # 查看 helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 查看必填字段
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -510,29 +543,29 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
|
||||
| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
|
||||
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
|
||||
| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
|
||||
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
|
||||
| DING | `ding` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
|
||||
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
|
||||
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
|
||||
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
|
||||
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
|
||||
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
|
||||
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
|
||||
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
|
||||
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
|
||||
| 直播 | `live` | 查看我的直播列表 |
|
||||
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
|
||||
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
@@ -593,7 +626,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ warehouse. This page is the integration contract.
|
||||
|
||||
| Header | Meaning | Granularity |
|
||||
|--------|---------|-------------|
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
|
||||
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
|
||||
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
|
||||
@@ -26,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as
|
||||
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
|
||||
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
|
||||
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
|
||||
5. **T4 — `custom`:** unknown host. Never guessed.
|
||||
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
@@ -55,8 +55,8 @@ MCP server config example (JSON-style hosts):
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
|
||||
verbatim so PAT grants and follow-up command checks use the same key.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
|
||||
+31
-16
@@ -4,23 +4,38 @@
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. `internal/market` fetches the registry and server metadata.
|
||||
2. `internal/discovery` resolves runtime server capabilities and caches results.
|
||||
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
|
||||
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
|
||||
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
|
||||
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
|
||||
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helper commands, and plugin commands.
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `cmd`: CLI entrypoint
|
||||
- `internal/app`: root command wiring and static utility commands
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
|
||||
- `internal/ir`: canonical intermediate representation for discovered tools
|
||||
- `internal/generator`: docs, schema, and skill generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
|
||||
- `skills/`: bundled agent skills source and generated skill docs
|
||||
- `test/`: CLI, compatibility, integration, contract, and script tests
|
||||
|
||||
## Public Repository Contract
|
||||
|
||||
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
|
||||
- `internal/cobracmd`: shared Cobra command builders
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
- `pkg/mcptypes`: MCP protocol type definitions
|
||||
- `internal/syncdata`: generated static endpoint and command-routing data synced from the Wukong baseline
|
||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
||||
|
||||
+26
-25
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
|
||||
## Project Snapshot
|
||||
|
||||
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
|
||||
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
|
||||
- Compatibility and helper surfaces are overlays, not the canonical truth.
|
||||
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
|
||||
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `cmd`: public CLI entrypoint
|
||||
- `internal/app`: root command wiring and command tree mount points
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
|
||||
- `internal/generator`: CLI/schema/docs/skills generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
|
||||
- `internal/app`: root command wiring, static utility commands, plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and static endpoint loader
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection
|
||||
- `internal/audit`: user operation audit log
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `docs/`: public architecture and reference docs
|
||||
- `hack/`: developer-only helper commands not shipped as public binaries
|
||||
- `scripts/`: build, test, lint, packaging, and policy checks
|
||||
- `test/`: integration, contract, compatibility, and script validation suites
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
|
||||
|
||||
## Task Routing
|
||||
|
||||
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
|
||||
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
|
||||
- Generated output drift: inspect `internal/generator` and run drift checks
|
||||
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
|
||||
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
|
||||
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
|
||||
- Protocol or transport issues: inspect `internal/transport`
|
||||
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
|
||||
## Generated Artifacts
|
||||
## Policy Checks
|
||||
|
||||
Prefer editing source logic instead of generated files directly.
|
||||
When command surface or plugin descriptors change, run:
|
||||
|
||||
- Generated-heavy paths:
|
||||
- `docs/generated/`
|
||||
- `skills/generated/`
|
||||
- `test/golden/generated_outputs/`
|
||||
- When generator or command surface changes, run:
|
||||
- `./scripts/policy/check-generated-drift.sh`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-open-source-assets.sh`
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -55,9 +59,6 @@ make build
|
||||
make test
|
||||
make lint
|
||||
./scripts/dev/ci-local.sh
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-command-surface.sh --strict
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
|
||||
## Global flags
|
||||
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
|
||||
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
|
||||
Stream long-connection. By default it runs in the foreground and dies when the
|
||||
terminal closes. For an unattended "digital employee" you have two options.
|
||||
|
||||
> **Security**: prefer `--unified-app-id <uappid>` over
|
||||
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
|
||||
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
|
||||
> so the secret never appears in `ps` / journald / shell history. Pasting
|
||||
> `--robot-client-secret` onto argv lets any local user read your AppSecret
|
||||
> with `ps -ef`; the CLI will warn you when you do that.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws devapp robot connect --daemon \
|
||||
dws dev connect --daemon \
|
||||
--channel claudecode \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
--unified-app-id <unifiedAppId>
|
||||
|
||||
# Inspect / stop it.
|
||||
dws devapp robot connect status --robot-client-id <clientId>
|
||||
dws devapp robot connect stop --robot-client-id <clientId>
|
||||
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
|
||||
dws dev connect status --unified-app-id <unifiedAppId>
|
||||
dws dev connect stop --unified-app-id <unifiedAppId>
|
||||
dws dev connect restart --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
- The parent prints the daemon pid and the log path, then exits.
|
||||
@@ -23,9 +31,9 @@ dws devapp robot connect stop --robot-client-id <clientId>
|
||||
10 consecutive fast failures) when it exits abnormally.
|
||||
- The single-instance lock (one connector per robot per machine) is reused, so a
|
||||
duplicate daemon refuses to start.
|
||||
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
|
||||
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<clientId>/daemon.pid`.
|
||||
`~/.dws/connect/<key>/daemon.pid`.
|
||||
- The daemon does NOT survive a reboot. For that, use Option B.
|
||||
|
||||
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
|
||||
@@ -39,7 +47,7 @@ restart it. This is the most robust way to get boot-time auto-start.
|
||||
### macOS — launchd
|
||||
|
||||
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
|
||||
and credentials, then `launchctl load -w <path>`.
|
||||
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -52,15 +60,12 @@ and credentials, then `launchctl load -w <path>`.
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/dws</string>
|
||||
<string>devapp</string>
|
||||
<string>robot</string>
|
||||
<string>dev</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--robot-client-id</string>
|
||||
<string>REPLACE_CLIENT_ID</string>
|
||||
<string>--robot-client-secret</string>
|
||||
<string>REPLACE_CLIENT_SECRET</string>
|
||||
<string>--unified-app-id</string>
|
||||
<string>REPLACE_UNIFIED_APP_ID</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -86,8 +91,8 @@ itself relies on the single-instance lock to avoid duplicates.
|
||||
|
||||
### Linux — systemd (user service)
|
||||
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
|
||||
then:
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
|
||||
`REPLACE_UNIFIED_APP_ID`, then:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
@@ -104,10 +109,9 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/dws devapp robot connect \
|
||||
ExecStart=/usr/local/bin/dws dev connect \
|
||||
--channel claudecode \
|
||||
--robot-client-id REPLACE_CLIENT_ID \
|
||||
--robot-client-secret REPLACE_CLIENT_SECRET
|
||||
--unified-app-id REPLACE_UNIFIED_APP_ID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Optional hardening:
|
||||
@@ -121,6 +125,22 @@ WantedBy=default.target
|
||||
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
|
||||
stderr into the journal (`journalctl --user -u dws-connect -f`).
|
||||
|
||||
## Legacy: passing clientId/clientSecret directly (not recommended)
|
||||
|
||||
If you truly must pass credentials on the command line (e.g. one-off local
|
||||
debugging without a unifiedAppId), the CLI still accepts
|
||||
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
|
||||
security warning to stderr. This form:
|
||||
|
||||
- exposes `clientSecret` to every user on the box via `ps -ef`;
|
||||
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
|
||||
makes rotation harder;
|
||||
- means `dws dev connect restart` cannot re-fetch credentials — you
|
||||
must re-run the full command yourself.
|
||||
|
||||
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
|
||||
trade-off.
|
||||
|
||||
## Which to choose
|
||||
|
||||
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
- **期望(分步)**:
|
||||
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
|
||||
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
|
||||
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
|
||||
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
|
||||
|
||||
### C9. 事件订阅与上游错误排查
|
||||
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DevApp 一键安装与 Agent 接入指南
|
||||
# dws dev 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
@@ -6,45 +6,44 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
### Windows(PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
|
||||
2. 使用本地源码构建 `dws`。
|
||||
3. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
|
||||
|
||||
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell 正式安装命令:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装脚本支持这些环境变量:
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
|
||||
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
|
||||
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
|
||||
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
|
||||
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
|
||||
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
|
||||
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
|
||||
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
### 国内加速
|
||||
|
||||
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
## 安装后验证
|
||||
|
||||
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 DevApp 命令存在:
|
||||
确认 `dws dev app` 命令存在:
|
||||
|
||||
```bash
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
|
||||
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
@@ -77,14 +76,18 @@ dws auth login
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws dev app list --format json
|
||||
```
|
||||
|
||||
## DevApp 是什么
|
||||
## dws dev 是什么
|
||||
|
||||
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
|
||||
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
|
||||
|
||||
它让 Agent 可以完成这些工作:
|
||||
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
|
||||
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
|
||||
- `dws dev doc` — 开放平台开发文档搜索。
|
||||
|
||||
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 创建、查询、更新、启用、停用机器人。
|
||||
- 异步创建机器人、配置/启停现有机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 DevApp。
|
||||
安装完成后,可以直接让 Agent 操作 `dws dev`。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先检查发布前置条件。
|
||||
帮我发布这个应用版本,先预检是否需要审批。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。先 dry-run:
|
||||
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
|
||||
|
||||
提交建号任务(记下返回的 `taskId`):
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
dws dev app robot submit \
|
||||
--name "告警助手" \
|
||||
--robot-name "告警机器人" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后执行:
|
||||
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--yes \
|
||||
--format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
```
|
||||
|
||||
回读验证:
|
||||
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws devapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp create --name "考勤应用" --dry-run --format json
|
||||
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app list --format json
|
||||
dws dev app get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app create --name "考勤应用" --dry-run --format json
|
||||
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
|
||||
```
|
||||
|
||||
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws devapp permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人配置
|
||||
### 机器人能力
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws devapp member list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
dws dev app member list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 网页应用与事件
|
||||
|
||||
```bash
|
||||
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
|
||||
dws dev app event list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
|
||||
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认。
|
||||
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
|
||||
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws devapp` 不存在
|
||||
### `dws dev app` 不存在
|
||||
|
||||
先确认安装的是预览分支源码,而不是正式 Release:
|
||||
先确认装上的是带 `dws dev` 的版本:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
|
||||
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
|
||||
|
||||
### `dws devapp list` 失败
|
||||
### `dws dev app list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
@@ -254,11 +269,13 @@ dws auth login
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 提示"当前用户没有开发者身份"
|
||||
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
|
||||
|
||||
先确认当前用户是否满足以下任一条件:
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
@@ -270,18 +287,17 @@ dws auth login
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,使用 `robot config` 创建或配置。
|
||||
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
|
||||
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install DevApp in one command.
|
||||
Install dws dev in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 DevApp。
|
||||
一行命令接入 dws dev。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-16
@@ -5,7 +5,7 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
|
||||
@@ -22,7 +22,7 @@
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
|
||||
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
|
||||
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
|
||||
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
@@ -34,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
|
||||
dws schema -f pretty "dev app create" # Pretty helper-only schema view / helper-only schema 彩色分区展示
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -51,26 +51,24 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
|
||||
静态端点模式下,产品命令和 flag 以当前二进制的 `--help` 与内置 Skill 为准。`dws schema` 仅保留 helper-only 子树(如 `dev.*`)的 schema 查询。
|
||||
|
||||
### 路径写法
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品 + 工具名
|
||||
dws schema ding.send_ding_message # canonical: product.rpc_name
|
||||
dws schema ding.message.send # CLI 点路径: product.group.cli_name
|
||||
dws schema "ding message send" # CLI 空格路径(同上)
|
||||
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
|
||||
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
|
||||
dws schema # 静态端点模式提示
|
||||
dws schema "dev app create" # CLI 空格路径
|
||||
dws schema --cli-path "dev app create" # 显式 flag(脚本友好,免转义)
|
||||
dws schema -f pretty "dev app create" # ANSI 着色分区展示(人肉查看最舒服)
|
||||
```
|
||||
|
||||
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
|
||||
helper-only schema 以 CLI 路径为准;普通产品命令请使用 `dws <path> --help` 查看参数。
|
||||
|
||||
### 单工具输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
|
||||
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / helper-only canonical path |
|
||||
| `group` | CLI 父级 group 路径(dot-separated) |
|
||||
| `title` / `description` | 工具名/说明(overlay 优先) |
|
||||
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
|
||||
@@ -85,10 +83,8 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl
|
||||
### 筛选输出
|
||||
|
||||
```bash
|
||||
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
|
||||
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
|
||||
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
|
||||
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
|
||||
dws schema "dev app create" --jq '.tool.parameters' # 只看参数 schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 只看必填字段
|
||||
```
|
||||
|
||||
## Shell Completion / 自动补全
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
> ```bash
|
||||
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
> ```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
@@ -50,18 +55,21 @@ dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "
|
||||
dws dev app robot result --task-id 上一步返回的taskId --format json
|
||||
```
|
||||
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
|
||||
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
|
||||
```
|
||||
|
||||
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
|
||||
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
|
||||
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
@@ -90,7 +98,7 @@ dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-cl
|
||||
```bash
|
||||
dws dev connect \
|
||||
--agent-cmd "你的AI命令 一次性问答参数" \
|
||||
--robot-client-id dingxxxx --robot-client-secret yyyy
|
||||
--unified-app-id 你的unifiedAppId
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.8
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
@@ -21,9 +24,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
@@ -44,7 +45,6 @@ require (
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
|
||||
+260
-56
@@ -39,11 +39,12 @@ import (
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
type authLoginGuideAction string
|
||||
@@ -109,10 +110,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -154,8 +156,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||||
}
|
||||
@@ -163,6 +166,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
|
||||
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
@@ -217,7 +225,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
|
||||
}
|
||||
|
||||
// Default table output
|
||||
@@ -225,7 +233,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
@@ -250,7 +258,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
@@ -373,58 +381,67 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息",
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息(默认退出所有组织)",
|
||||
Long: `清除本机钉钉登录态。
|
||||
|
||||
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
|
||||
Example: ` dws auth logout
|
||||
dws auth logout --profile <corpId>
|
||||
dws auth logout --profile "钉钉"`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||
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 strings.TrimSpace(profileSelector) != "" {
|
||||
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret and app token from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||||
}
|
||||
// Also try cleaning app token using appKey from app config
|
||||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||||
_ = authpkg.DeleteAppTokenData(appKey)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token.json"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
fmt.Fprintln(w, "[OK] 已清除认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
Long: `查看当前或指定组织 profile 的认证状态。
|
||||
|
||||
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
|
||||
Example: ` dws auth status
|
||||
dws auth status --profile <corpId>
|
||||
dws auth status --profile "钉钉"
|
||||
dws auth status --profile <corpId> --format json`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
|
||||
authenticated := false
|
||||
refreshed := false
|
||||
@@ -444,6 +461,8 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -467,6 +486,12 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
}
|
||||
if tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||||
} else {
|
||||
@@ -485,6 +510,56 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(selector)
|
||||
defer restoreProfile()
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
restoreProfile()
|
||||
}
|
||||
}
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRuntimeProfile(selector string) func() {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return func() {}
|
||||
}
|
||||
previous := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile(selector)
|
||||
return func() {
|
||||
authpkg.SetRuntimeProfile(previous)
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthExportCommand() *cobra.Command {
|
||||
@@ -683,11 +758,12 @@ func newAuthResetCommand() *cobra.Command {
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -862,10 +938,6 @@ func authLoginMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive)
|
||||
}
|
||||
@@ -934,10 +1006,7 @@ func clipRunes(value string, limit int) string {
|
||||
}
|
||||
|
||||
func clearCompatCache() {
|
||||
store := cacheStoreFromEnv()
|
||||
if store != nil {
|
||||
_ = os.RemoveAll(store.Root)
|
||||
}
|
||||
// Cache store removed; no-op in static endpoint mode.
|
||||
}
|
||||
|
||||
func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
@@ -958,18 +1027,153 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||
}
|
||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
}
|
||||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
return selector, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
|
||||
type contactProfileIdentity struct {
|
||||
CorpID string
|
||||
CorpName string
|
||||
UserID string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
if caller == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
restoreProfile := pushRuntimeProfile(corpID)
|
||||
defer restoreProfile()
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
|
||||
"profile": corpID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||||
}
|
||||
|
||||
updated := *data
|
||||
if identity.CorpName != "" {
|
||||
updated.CorpName = identity.CorpName
|
||||
}
|
||||
if identity.UserID != "" {
|
||||
updated.UserID = identity.UserID
|
||||
}
|
||||
if identity.UserName != "" {
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
return nil
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = updated
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||||
if result == nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
for _, block := range result.Content {
|
||||
if strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||||
var payload struct {
|
||||
Result []struct {
|
||||
OrgEmployeeModel struct {
|
||||
CorpID string `json:"corpId"`
|
||||
OrgName string `json:"orgName"`
|
||||
UserID string `json:"userId"`
|
||||
UserIDLower string `json:"userid"`
|
||||
OrgUserName string `json:"orgUserName"`
|
||||
Name string `json:"name"`
|
||||
} `json:"orgEmployeeModel"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
if len(payload.Result) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
org := payload.Result[0].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func authStatusAuthenticated(data *authpkg.TokenData) bool {
|
||||
if data == nil {
|
||||
return false
|
||||
|
||||
@@ -184,6 +184,161 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"corp_primary org", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
|
||||
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: "client-app",
|
||||
ClientSecret: authpkg.PlainSecret("secret-app"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
|
||||
t.Fatalf("profiles after logout = %#v, want empty", cfg)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("primary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("secondary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychain() {
|
||||
t.Fatal("legacy auth-token mirror should be deleted")
|
||||
}
|
||||
appConfig, err := authpkg.LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig() error = %v", err)
|
||||
}
|
||||
if appConfig == nil || appConfig.ClientID != "client-app" {
|
||||
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
|
||||
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("selected primary profile token should be deleted")
|
||||
}
|
||||
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("unselected secondary profile token should be retained")
|
||||
}
|
||||
loaded, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
|
||||
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
@@ -297,6 +452,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
}
|
||||
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
|
||||
t.Fatal("Force=false should still force authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
@@ -578,6 +742,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
token := &authpkg.TokenData{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "ding32fff839a3e0105d",
|
||||
ClientID: "client-id",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
}}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
}
|
||||
if token.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
|
||||
}
|
||||
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
|
||||
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
|
||||
}
|
||||
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
||||
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -642,3 +853,41 @@ func stringSliceArgEqual(got any, want []string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
t.Cleanup(func() {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
})
|
||||
|
||||
for _, token := range tokens {
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
|
||||
}
|
||||
}
|
||||
return configDir
|
||||
}
|
||||
|
||||
func authLogoutTestToken(corpID string) *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access-" + corpID,
|
||||
RefreshToken: "refresh-" + corpID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpID + " org",
|
||||
UserID: "user-" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client-" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestPluginAuthRegistry(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-product")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
// Initially not found
|
||||
if _, ok := LookupPluginAuth("test-product"); ok {
|
||||
t.Error("expected LookupPluginAuth to return false for unregistered product")
|
||||
}
|
||||
|
||||
// Register auth credentials
|
||||
auth := &PluginAuth{
|
||||
Token: "sk-test-token-12345",
|
||||
ExtraHeaders: map[string]string{"X-Custom": "value"},
|
||||
TrustedDomains: []string{"api.example.com", "*.example.com"},
|
||||
}
|
||||
RegisterPluginAuth("test-product", auth)
|
||||
|
||||
// Now should be found
|
||||
got, ok := LookupPluginAuth("test-product")
|
||||
if !ok {
|
||||
t.Fatal("expected LookupPluginAuth to return true after registration")
|
||||
}
|
||||
if got != auth {
|
||||
t.Error("LookupPluginAuth returned different auth instance")
|
||||
}
|
||||
if got.Token != "sk-test-token-12345" {
|
||||
t.Errorf("Token = %q, want sk-test-token-12345", got.Token)
|
||||
}
|
||||
if got.ExtraHeaders["X-Custom"] != "value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want value", got.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(got.TrustedDomains) != 2 {
|
||||
t.Errorf("TrustedDomains len = %d, want 2", len(got.TrustedDomains))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAuthRegistryIsolation(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "product-a")
|
||||
delete(pluginAuthRegistry, "product-b")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
authA := &PluginAuth{Token: "token-a"}
|
||||
authB := &PluginAuth{Token: "token-b"}
|
||||
|
||||
RegisterPluginAuth("product-a", authA)
|
||||
RegisterPluginAuth("product-b", authB)
|
||||
|
||||
gotA, okA := LookupPluginAuth("product-a")
|
||||
gotB, okB := LookupPluginAuth("product-b")
|
||||
|
||||
if !okA || !okB {
|
||||
t.Fatal("expected both products to be registered")
|
||||
}
|
||||
if gotA.Token != "token-a" {
|
||||
t.Errorf("product-a Token = %q, want token-a", gotA.Token)
|
||||
}
|
||||
if gotB.Token != "token-b" {
|
||||
t.Errorf("product-b Token = %q, want token-b", gotB.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveToolCLIName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"web_search", "web-search"},
|
||||
{"maps.search_poi", "search-poi"},
|
||||
{"maps.geo", "geo"},
|
||||
{"simple", "simple"},
|
||||
{"a.b.deep_nested_name", "deep-nested-name"},
|
||||
{"already-kebab", "already-kebab"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := deriveToolCLIName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveToolCLIName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeaders(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-srv")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "test-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "test-srv", Command: "test-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-my-secret-key",
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
auth, ok := LookupPluginAuth("test-srv")
|
||||
if !ok {
|
||||
t.Fatal("expected auth to be registered after registerPluginAuthFromHeaders")
|
||||
}
|
||||
if auth.Token != "sk-my-secret-key" {
|
||||
t.Errorf("Token = %q, want sk-my-secret-key", auth.Token)
|
||||
}
|
||||
if auth.ExtraHeaders["X-Custom"] != "custom-value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want custom-value", auth.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(auth.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(auth.TrustedDomains))
|
||||
}
|
||||
if auth.TrustedDomains[0] != "api.example.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want api.example.com", auth.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeadersNoAuth(t *testing.T) {
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "no-auth-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "no-auth-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
// Should not register because there's no Authorization header
|
||||
if _, ok := LookupPluginAuth("no-auth-srv"); ok {
|
||||
t.Error("expected no auth registration when Authorization header is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClient(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/mcp",
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-test-api-key",
|
||||
"X-Extra": "extra-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return a different client instance
|
||||
if client == base {
|
||||
t.Error("expected buildPluginAuthClient to return a new client, not the base")
|
||||
}
|
||||
|
||||
// Verify trusted domains
|
||||
if len(client.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(client.TrustedDomains))
|
||||
}
|
||||
if client.TrustedDomains[0] != "dashscope.aliyuncs.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want dashscope.aliyuncs.com", client.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClientNoAuth(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return the base client when no Authorization header
|
||||
if client != base {
|
||||
t.Error("expected buildPluginAuthClient to return base client when no Authorization header")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, err := json.MarshalIndent(notice, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
|
||||
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
|
||||
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
|
||||
// named after the reserved --params flag) aborted every invocation. With no
|
||||
// on-disk cache to quarantine it must degrade to an inert stub instead.
|
||||
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
|
||||
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
|
||||
// the build panics AND a discovery cache exists on disk, the partition is
|
||||
// quarantined and the build retried once, so a fixed binary escapes the
|
||||
// lock-out with zero manual cache surgery.
|
||||
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Short != "rebuilt-probe" {
|
||||
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
|
||||
}
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
|
||||
// if the rebuild after quarantine panics again, the stub is returned and the
|
||||
// `dws cache refresh` hint kept.
|
||||
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
|
||||
// on the happy path.
|
||||
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
cmd := newMCPCommand(context.Background(), nil, nil, nil)
|
||||
if cmd == nil || cmd.Short != "canonical-probe" {
|
||||
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "查看服务目录 (静态端点模式)",
|
||||
Hidden: true,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -57,12 +57,12 @@ func devappMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + devappServerPath
|
||||
}
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
func defaultPATServerDescriptor() mcptypes.ServerDescriptor {
|
||||
return mcptypes.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
@@ -104,7 +104,7 @@ func defaultPATGatewayBaseURL() string {
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
@@ -167,7 +167,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
@@ -363,7 +363,7 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
// dynamic server registry without replacing the current entries. This
|
||||
// is used by the plugin loader to inject plugin servers alongside
|
||||
// Market-discovered servers.
|
||||
func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
func AppendDynamicServer(server mcptypes.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// Regression for the chat/bot tool routing bug: when the `chat` envelope
|
||||
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
|
||||
// `send_message_by_custom_robot`), those tool names must NOT be registered
|
||||
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
|
||||
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
|
||||
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
|
||||
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
|
||||
// tool.
|
||||
//
|
||||
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
|
||||
// side), so product-level and tool-level lookups both resolve correctly.
|
||||
|
||||
const (
|
||||
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
|
||||
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
|
||||
)
|
||||
|
||||
// botDescriptor returns a minimal `bot` server descriptor that owns the
|
||||
// `search_my_robots` + `send_message_by_custom_robot` tools (no
|
||||
// serverOverride — bot is the real owner).
|
||||
func botDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testBotEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "bot",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_my_robots": {CLIName: "search"},
|
||||
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
|
||||
"add_robot_to_group": {CLIName: "add-bot"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// chatDescriptor returns a minimal `chat` server descriptor whose
|
||||
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
|
||||
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
|
||||
// chat's endpoint.
|
||||
func chatDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testChatEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "chat",
|
||||
Command: "chat",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_groups_by_keyword": {CLIName: "search"},
|
||||
"search_my_robots": {
|
||||
CLIName: "search",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"send_message_by_custom_robot": {
|
||||
CLIName: "send-by-webhook",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"add_robot_to_group": {
|
||||
CLIName: "add-bot",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
|
||||
// registries so parallel/other tests aren't affected by this case's mutations.
|
||||
func withCleanDynamicRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func assertEndpoint(t *testing.T, productID, toolName, want string) {
|
||||
t.Helper()
|
||||
got, ok := directRuntimeEndpoint(productID, toolName)
|
||||
if !ok {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
|
||||
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
|
||||
// of registration order.
|
||||
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "bot first, chat second",
|
||||
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "chat first, bot second",
|
||||
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Bot-owned tools must route to bot's endpoint even though chat
|
||||
// declares toolOverrides for them (with serverOverride="bot").
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
|
||||
|
||||
// Chat-native tools must still route to chat.
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
|
||||
// Product-level fallback for bot (no tool name) must also return
|
||||
// bot's endpoint.
|
||||
assertEndpoint(t, "bot", "", testBotEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
|
||||
// the plugin-injection path (`AppendDynamicServer`) which has the same
|
||||
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
|
||||
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
|
||||
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
orders := [][]market.ServerDescriptor{
|
||||
{botDescriptor(), chatDescriptor()},
|
||||
{chatDescriptor(), botDescriptor()},
|
||||
}
|
||||
|
||||
for _, servers := range orders {
|
||||
t.Run("", func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
for _, s := range servers {
|
||||
AppendDynamicServer(s)
|
||||
}
|
||||
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #219 regression tests: cross-product tool name collision ---
|
||||
//
|
||||
// When two different products register tools with the same name (e.g. drive
|
||||
// and doc both have "create_folder"), the product-level endpoint must win
|
||||
// when the caller already knows the productID. Otherwise the tool-level map
|
||||
// (last-writer-wins) routes the invocation to the wrong MCP server.
|
||||
|
||||
const (
|
||||
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
|
||||
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
|
||||
)
|
||||
|
||||
func driveDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDriveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "drive",
|
||||
Command: "drive",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "mkdir"},
|
||||
"list_files": {CLIName: "list"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"get_upload_info": {CLIName: "upload-info"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDocEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "create", Group: "folder"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"search_documents": {CLIName: "search"},
|
||||
"list_nodes": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
|
||||
// that when productID is known and has a registered endpoint, the product-level
|
||||
// endpoint is used even if the tool-level map points to a different server
|
||||
// (due to same-name tool collision). This is the core fix for issue #219.
|
||||
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "drive first, doc second",
|
||||
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "doc first, drive second",
|
||||
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Drive tools must always route to drive's endpoint regardless of
|
||||
// registration order — productID "drive" is known.
|
||||
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
|
||||
|
||||
// Doc tools must always route to doc's endpoint.
|
||||
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
|
||||
|
||||
// Product-level fallback (no tool name) still works.
|
||||
assertEndpoint(t, "drive", "", testDriveEndpoint)
|
||||
assertEndpoint(t, "doc", "", testDocEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
|
||||
|
||||
// When productID is empty, tool-level endpoint is the only option.
|
||||
// The actual endpoint depends on registration order (last-writer-wins),
|
||||
// but the lookup must succeed.
|
||||
endpoint, ok := directRuntimeEndpoint("", "create_folder")
|
||||
if !ok {
|
||||
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
|
||||
}
|
||||
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
|
||||
}
|
||||
|
||||
// Unique tools (no collision) still resolve via tool-level.
|
||||
assertEndpoint(t, "", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
server := defaultPATServerDescriptor()
|
||||
if server.CLI.ID != "pat" {
|
||||
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
|
||||
}
|
||||
if server.DisplayName != "行为授权" {
|
||||
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
|
||||
}
|
||||
if server.Endpoint != defaultPATMCPEndpoint() {
|
||||
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousProducts := dynamicProducts
|
||||
dynamicProducts = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicProducts = previousProducts
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["pat"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["devapp"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://example.test/server/devapp-supplement",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "devapp",
|
||||
Command: "devapp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-static",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement",
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
customEndpoint := "https://example.com/server/custom-pat"
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: customEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "pat",
|
||||
Command: "pat",
|
||||
},
|
||||
},
|
||||
})
|
||||
assertEndpoint(t, "pat", "", customEndpoint)
|
||||
}
|
||||
|
||||
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousAliases := dynamicAliases
|
||||
dynamicAliases = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicAliases = previousAliases
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
cases := map[string]string{
|
||||
"tb": "teambition",
|
||||
"dingtalk-discovery": "discovery",
|
||||
"dingtalk-oa-plus": "oa",
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := normalizeDirectRuntimeProductID(input); got != want {
|
||||
t.Fatalf("normalizeDirectRuntimeProductID(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ import (
|
||||
"time"
|
||||
|
||||
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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -193,15 +191,12 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
client := market.NewClient(baseURL, httpClient)
|
||||
|
||||
start := time.Now()
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.FetchServers(reqCtx, 1)
|
||||
latency := time.Since(start)
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
@@ -215,6 +210,22 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
||||
Hint: "请检查网络连接或代理设置",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusPass,
|
||||
@@ -233,64 +244,10 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
files, _, err := cacheDirectoryStats(store.Root)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("缓存目录不可读: %v", err),
|
||||
Hint: "运行 dws cache clean 清理后重试",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
entries, _ := store.ListToolsCacheEntries(config.DefaultPartition)
|
||||
|
||||
if files == 0 && len(entries) == 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: "缓存为空 (首次使用)",
|
||||
Hint: "运行任意 dws 命令后将自动建立缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
staleCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Freshness == cache.FreshnessStale {
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
|
||||
if staleCount > 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: fmt.Sprintf("%d 个文件, %d 个工具缓存, %d 个已过期", files, len(entries), staleCount),
|
||||
Hint: "运行 dws cache refresh 刷新缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%d 个文件, %d 个工具缓存", files, len(entries))
|
||||
if len(entries) > 0 {
|
||||
msg += ", 全部新鲜"
|
||||
}
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: msg,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
|
||||
@@ -108,11 +108,8 @@ func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "缓存为空") {
|
||||
t.Errorf("expected empty cache message, got %q", r.Message)
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +119,8 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
|
||||
@@ -29,6 +29,7 @@ type GlobalFlags struct {
|
||||
JQ string
|
||||
Mock bool
|
||||
Output string
|
||||
Profile string
|
||||
Timeout int
|
||||
Token string
|
||||
Verbose bool
|
||||
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
_ = cmd.PersistentFlags().MarkHidden("output")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
|
||||
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
|
||||
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
|
||||
_ = cmd.PersistentFlags().MarkHidden("token")
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 3, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("doc", "文档管理", nil, map[string]any{
|
||||
"search_docs": map[string]any{
|
||||
"cliName": "search",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("chat", "聊天管理", map[string]any{
|
||||
"message": map[string]any{"description": "消息管理"},
|
||||
}, map[string]any{
|
||||
"list_messages": map[string]any{
|
||||
"cliName": "list",
|
||||
"group": "message",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("minutes", "听记管理", map[string]any{
|
||||
"list": map[string]any{"description": "列表"},
|
||||
}, map[string]any{
|
||||
"list_minutes_mine": map[string]any{
|
||||
"cliName": "mine",
|
||||
"group": "list",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat message list-topic-replies",
|
||||
"minutes list all",
|
||||
} {
|
||||
if cmd := lookupCommand(root, path); cmd != nil {
|
||||
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicLeafHelpDoesNotUsePatchedExamplesOrFlagText(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"alias": "prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"aiapp", "create", "--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(aiapp create --help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if strings.Contains(got, "创建一个天气查询应用") {
|
||||
t.Fatalf("leaf help still contains patched example:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "创建 AI 应用的 prompt(必填)") {
|
||||
t.Fatalf("leaf help still contains patched flag usage:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--prompt string") {
|
||||
t.Fatalf("leaf help missing dynamic prompt flag:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("aitable", "多维表管理", nil, map[string]any{
|
||||
"list_bases": map[string]any{
|
||||
"cliName": "list",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(--help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"Discovered MCP Services:", "aiapp", "AI应用管理", "aitable", "多维表管理"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"alias": "prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"aiapp", "--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(aiapp --help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Usage:") || !strings.Contains(got, "Available Commands:") || !strings.Contains(got, "Flags:") {
|
||||
t.Fatalf("subcommand help should still use cobra default sections:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Discovered MCP Services:") {
|
||||
t.Fatalf("subcommand help should not render root-only MCP summary:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
t.Fatal("upgrade command should be registered on root, but was not found")
|
||||
}
|
||||
}
|
||||
|
||||
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
|
||||
cliMeta := map[string]any{
|
||||
"id": command,
|
||||
"command": command,
|
||||
"description": description,
|
||||
"toolOverrides": toolOverrides,
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
cliMeta["groups"] = groups
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": command,
|
||||
"description": description,
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + command,
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active",
|
||||
"isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": cliMeta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func lookupCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
if root == nil || path == "" {
|
||||
return root
|
||||
}
|
||||
|
||||
cmd := root
|
||||
for _, part := range strings.Fields(path) {
|
||||
found := false
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == part {
|
||||
cmd = child
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
+18
-554
@@ -14,170 +14,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"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/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"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)
|
||||
}
|
||||
|
||||
return buildEnvelopeCommandsSafe(ctx, runner)
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
|
||||
// panic in the cache-driven build can be simulated without crafting a
|
||||
// poisoned on-disk cache.
|
||||
var loadDynamicCommandsFn = loadDynamicCommands
|
||||
func injectStaticServers() {
|
||||
hooks := edition.Get()
|
||||
var servers []edition.ServerInfo
|
||||
|
||||
// buildEnvelopeCommandsSafe builds the public command set from the discovery
|
||||
// envelope, self-healing a poisoned cache when the dynamic build panics and
|
||||
// degrading to the hardcoded helper commands only if that also fails.
|
||||
//
|
||||
// Why this guard exists: the dynamic command tree is constructed from cached
|
||||
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
|
||||
// a duplicate pflag registration fed by a poisoned cache, as seen before
|
||||
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
|
||||
// every invocation — including `dws cache refresh`, the very command that
|
||||
// repairs the cache.
|
||||
//
|
||||
// Recovery is two-staged. First the partition's discovery cache is moved
|
||||
// aside (kept on disk for inspection) and the build retried against a fresh
|
||||
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
|
||||
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
|
||||
// panics again (e.g. the remote envelope itself is still poisoned, or the
|
||||
// machine is offline with no usable cache) does the CLI degrade to utility
|
||||
// and helper commands with a `dws cache refresh` hint.
|
||||
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
return cmds
|
||||
if fn := hooks.StaticServers; fn != nil {
|
||||
servers = append(servers, fn()...)
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
|
||||
|
||||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||||
if qErr != nil {
|
||||
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
|
||||
}
|
||||
if quarantined != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||||
panicked, quarantined)
|
||||
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
|
||||
return cmds
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
|
||||
if fn := hooks.SupplementServers; fn != nil {
|
||||
servers = append(servers, fn()...)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"Product commands are temporarily unavailable; utility commands still work.\n"+
|
||||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||||
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
|
||||
}
|
||||
|
||||
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
|
||||
// converting a panic into a return value so the caller can decide between
|
||||
// self-heal and degradation.
|
||||
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cmds = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
|
||||
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
// Post-merge product hooks: tasks the envelope cannot express on its
|
||||
// own (e.g. dual-role group+leaf semantics for deprecated aliases).
|
||||
// Keep each hook narrowly scoped to one product so the open-source
|
||||
// command surface remains predictable from the envelope alone.
|
||||
helpers.AttachReportLegacyInboxAlias(merged, runner)
|
||||
helpers.AttachReportListReadableEnrichment(merged, runner)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
// same-named top-level products, helper-only leaves are grafted into the
|
||||
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
|
||||
// remains the authority for leaves it declares, while hardcoded helpers can
|
||||
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
|
||||
// alongside the envelope's `chat message send`).
|
||||
//
|
||||
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
|
||||
// on same-named top-level commands, which — at leaf conflicts — falls back to
|
||||
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
|
||||
// typically expose more flags than the corresponding dynamic overlay leaves,
|
||||
// so a naive append would silently promote helper leaves over their dynamic
|
||||
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
|
||||
// leaf conflict, and only adding subtrees the dynamic side lacks.
|
||||
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
|
||||
dynByName := make(map[string]*cobra.Command, len(dynamic))
|
||||
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
|
||||
for _, c := range dynamic {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
dynByName[c.Name()] = c
|
||||
out = append(out, c)
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, h := range helpers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if dyn := dynByName[h.Name()]; dyn != nil {
|
||||
cmdutil.MergeHardcodedLeaves(dyn, h)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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))
|
||||
descriptors := make([]mcptypes.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
descriptors = append(descriptors, mcptypes.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
@@ -187,412 +62,6 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
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;
|
||||
// after that it revalidates against the live market registry. Once the hard
|
||||
// RegistryTTL expires, a successful live registry fetch triggers a full detail
|
||||
// refresh for every server so command metadata cannot stay pinned to an
|
||||
// arbitrarily old snapshot. On network failure with a stale cache, it
|
||||
// gracefully degrades to the cached data so the CLI remains functional
|
||||
// offline.
|
||||
//
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
// editionPartition returns the cache partition for the active edition.
|
||||
// Thin wrapper around config.EditionPartition; kept so the many existing
|
||||
// call sites in internal/app don't need to thread edition.Get() everywhere.
|
||||
func editionPartition() string {
|
||||
return config.EditionPartition(edition.Get().Name)
|
||||
}
|
||||
|
||||
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
|
||||
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
|
||||
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
|
||||
func discoveryTraceEnabled() bool {
|
||||
if IsPerfDebugEnabled() {
|
||||
return true
|
||||
}
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--debug" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range servers {
|
||||
id := strings.TrimSpace(s.CLI.Command)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(s.CLI.ID)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
const maxIDs = 48
|
||||
if len(out) > maxIDs {
|
||||
out = out[:maxIDs]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
store := cacheStoreFromEnv()
|
||||
partition := editionPartition()
|
||||
|
||||
// Bypass the registry cache when a fixture override is active.
|
||||
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
|
||||
// data from their local mock server without interference from a
|
||||
// stale on-disk cache written by a previous production run.
|
||||
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
|
||||
|
||||
// --- Cache-first server registry ---
|
||||
cacheLoadStart := time.Now()
|
||||
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
|
||||
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
|
||||
|
||||
var servers []market.ServerDescriptor
|
||||
now := store.Now().UTC()
|
||||
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
|
||||
|
||||
if usingCachedRegistry {
|
||||
servers = snapshot.Servers
|
||||
// Only trigger async revalidation in production (no URL override).
|
||||
// Tests set discoveryBaseURLOverride and control cache expiry directly,
|
||||
// so background revalidation would interfere with test expectations.
|
||||
if discoveryBaseURLOverride == "" && (freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, snapshot.SavedAt)) {
|
||||
go asyncRevalidateRegistry(ctx, store, partition)
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) > 0 && discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
|
||||
"partition", partition,
|
||||
"servers", len(servers),
|
||||
"registry_freshness", string(freshness))
|
||||
}
|
||||
|
||||
// Cache miss or bypassed: fetch from market API synchronously (first run only).
|
||||
if len(servers) == 0 {
|
||||
if discoveryTraceEnabled() {
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL())
|
||||
}
|
||||
}
|
||||
fetchStart := time.Now()
|
||||
|
||||
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
|
||||
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
|
||||
if fetchErr != nil {
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch failed",
|
||||
"partition", partition,
|
||||
"error", fetchErr.Error())
|
||||
}
|
||||
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
|
||||
// Degrade to stale cache if available (production only).
|
||||
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
|
||||
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
|
||||
servers = snapshot.Servers
|
||||
} else {
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
"response_servers", len(resp.Servers),
|
||||
"metadata_count", resp.Metadata.Count,
|
||||
"normalized_servers", len(servers),
|
||||
"cli_command_ids", discoveryTraceServerIDs(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)
|
||||
}
|
||||
RecordTiming(ctx, "cache_save", time.Since(saveStart))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FallbackServers: safety net when Market discovery + cache both fail.
|
||||
if len(servers) == 0 {
|
||||
if fn := edition.Get().FallbackServers; fn != nil {
|
||||
if fb := fn(); len(fb) > 0 {
|
||||
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
|
||||
descriptors := editionmerge.FallbackToDescriptors(fb)
|
||||
descriptors = editionmerge.MergeSupplement(descriptors)
|
||||
SetDynamicServers(descriptors)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge edition-specific supplement servers (not in Market).
|
||||
servers = editionmerge.MergeSupplement(servers)
|
||||
// Inject dynamic server data for endpoint resolution
|
||||
SetDynamicServers(servers)
|
||||
|
||||
detailStart := time.Now()
|
||||
detailsByID := loadCachedDetailsFast(store, servers)
|
||||
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
|
||||
|
||||
buildStart := time.Now()
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
|
||||
// no network calls. Returns whatever is available (fresh or stale).
|
||||
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
|
||||
result := make(map[string][]market.DetailTool)
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
if server.DetailLocator.MCPID <= 0 {
|
||||
continue
|
||||
}
|
||||
serverID := strings.TrimSpace(server.CLI.ID)
|
||||
if serverID == "" {
|
||||
continue
|
||||
}
|
||||
snap, _, err := store.LoadDetail(partition, serverID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Tools []market.DetailTool `json:"tools"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
|
||||
result[serverID] = payload.Tools
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fetchDetailsByServerID fetches MCP Detail API tool metadata for each server
|
||||
// with a known mcpId. Returns a map from CLI server ID → []DetailTool.
|
||||
// Results are read from / written to the disk cache (DetailTTL=7d).
|
||||
// All network fetches run concurrently; best-effort (errors silently skip).
|
||||
func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers []market.ServerDescriptor, store *cache.Store, forceRefresh bool) map[string][]market.DetailTool {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
partition := editionPartition()
|
||||
now := time.Now().UTC()
|
||||
if store != nil && store.Now != nil {
|
||||
now = store.Now().UTC()
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
id string
|
||||
tools []market.DetailTool
|
||||
}
|
||||
|
||||
results := make(chan entry, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, server := range servers {
|
||||
mcpID := server.DetailLocator.MCPID
|
||||
if mcpID <= 0 {
|
||||
continue
|
||||
}
|
||||
serverID := strings.TrimSpace(server.CLI.ID)
|
||||
if serverID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(srv market.ServerDescriptor, sID string, mID int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("fetchDetailsByServerID: goroutine panicked", "server", sID, "panic", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Cache hit check. Fresh entries within the short revalidation window
|
||||
// are returned immediately. Older entries still serve as fallback if
|
||||
// the live market detail request fails.
|
||||
var cachedTools []market.DetailTool
|
||||
haveCachedTools := false
|
||||
if store != nil {
|
||||
if snap, freshness, err := store.LoadDetail(partition, sID); err == nil {
|
||||
var payload struct {
|
||||
Tools []market.DetailTool `json:"tools"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
|
||||
cachedTools = payload.Tools
|
||||
haveCachedTools = true
|
||||
}
|
||||
if !forceRefresh && freshness == cache.FreshnessFresh && haveCachedTools && !cache.ShouldRevalidate(now, snap.SavedAt) {
|
||||
slog.Debug("fetchDetailsByServerID: using cached detail", "id", sID)
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network fetch with per-server 5s timeout.
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var detail market.DetailResponse
|
||||
var fetchErr error
|
||||
detailURL := strings.TrimSpace(srv.DetailLocator.DetailURL)
|
||||
if detailURL != "" {
|
||||
detail, fetchErr = client.FetchDetailByURL(fetchCtx, detailURL)
|
||||
} else {
|
||||
detail, fetchErr = client.FetchDetail(fetchCtx, mID)
|
||||
}
|
||||
if fetchErr != nil {
|
||||
slog.Debug("fetchDetailsByServerID: skipping server", "id", sID, "mcpId", mID, "error", fetchErr)
|
||||
if haveCachedTools {
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
}
|
||||
return
|
||||
}
|
||||
if !detail.Success || len(detail.Result.Tools) == 0 {
|
||||
if haveCachedTools {
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to cache.
|
||||
if store != nil {
|
||||
if payload, marshalErr := json.Marshal(map[string]any{"tools": detail.Result.Tools}); marshalErr == nil {
|
||||
if saveErr := store.SaveDetail(partition, sID, cache.DetailSnapshot{
|
||||
MCPID: mID,
|
||||
Payload: payload,
|
||||
}); saveErr != nil {
|
||||
slog.Debug("fetchDetailsByServerID: failed to save detail cache", "id", sID, "error", saveErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("fetchDetailsByServerID: got tool details", "id", sID, "tools", len(detail.Result.Tools))
|
||||
results <- entry{id: sID, tools: detail.Result.Tools}
|
||||
}(server, serverID, mcpID)
|
||||
}
|
||||
|
||||
// Close channel after all goroutines finish.
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
result := make(map[string][]market.DetailTool)
|
||||
for e := range results {
|
||||
result[e.id] = e.tools
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// discoveryBaseURLOverride allows tests to redirect discovery to a local server.
|
||||
// Must be empty in production; only set during test execution.
|
||||
var discoveryBaseURLOverride string
|
||||
|
||||
// SetDiscoveryBaseURL sets the base URL used for dynamic server discovery.
|
||||
// Intended for test use only.
|
||||
func SetDiscoveryBaseURL(url string) {
|
||||
discoveryBaseURLOverride = url
|
||||
}
|
||||
|
||||
// DiscoveryBaseURL returns the effective base URL for discovery —
|
||||
// discoveryBaseURLOverride if set, otherwise DefaultMarketBaseURL.
|
||||
func DiscoveryBaseURL() string {
|
||||
if discoveryBaseURLOverride != "" {
|
||||
return discoveryBaseURLOverride
|
||||
}
|
||||
return config.GetMCPBaseURL()
|
||||
}
|
||||
|
||||
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
|
||||
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
|
||||
// hosts without IPv6 networking.
|
||||
func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
|
||||
// suitable for CLI startup network requests.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
return ipv4HTTPClient(5 * time.Second)
|
||||
}
|
||||
|
||||
// fetchRegistryServers performs the server-list HTTP fetch honoring the
|
||||
// active edition's DiscoveryURL override. It is the single source of truth
|
||||
// for all server-list fetches (startup, async revalidation, explicit
|
||||
// `cache refresh`); keeping the edition-URL branch in one place prevents
|
||||
// call sites from drifting out of sync.
|
||||
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
client := market.NewClient("", httpClient)
|
||||
if fn := edition.Get().DiscoveryHeaders; fn != nil {
|
||||
client.Headers = fn()
|
||||
}
|
||||
return client.FetchServersFromURL(ctx, editionURL)
|
||||
}
|
||||
client := market.NewClient(DiscoveryBaseURL(), httpClient)
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
func registryDiscoveryBaseURL() string {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
return editionURL
|
||||
}
|
||||
return DiscoveryBaseURL()
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partition string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
if err != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
}
|
||||
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
@@ -623,8 +92,3 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSupplementServers / fallbackToDescriptors have moved to
|
||||
// internal/editionmerge so that both internal/cli and internal/app can
|
||||
// apply the edition's SupplementServers / FallbackServers hooks against
|
||||
// the same discovery pipeline (command tree + runtime catalog).
|
||||
|
||||
@@ -1,743 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/market"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// marketListResponse builds a minimal valid FetchServers JSON response.
|
||||
// The server has a ToolOverride so BuildDynamicCommands emits a command.
|
||||
func marketListResponse(cliID string) map[string]any {
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": "Test Server",
|
||||
"description": "desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/test/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active", "isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": cliID,
|
||||
"command": cliID,
|
||||
"toolOverrides": map[string]any{
|
||||
"test_tool": map[string]any{
|
||||
"cliName": "test",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type testCLIServerSpec struct {
|
||||
id string
|
||||
command string
|
||||
tool string
|
||||
cliName string
|
||||
}
|
||||
|
||||
func marketListResponseForSpecs(specs ...testCLIServerSpec) map[string]any {
|
||||
servers := make([]any, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
servers = append(servers, map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": spec.command,
|
||||
"description": spec.command + " desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + spec.command + "/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active", "isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": spec.id,
|
||||
"command": spec.command,
|
||||
"toolOverrides": map[string]any{
|
||||
spec.tool: map[string]any{
|
||||
"cliName": spec.cliName,
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": len(servers), "nextCursor": ""},
|
||||
"servers": servers,
|
||||
}
|
||||
}
|
||||
|
||||
// minimalCLIServer returns a ServerDescriptor with ToolOverrides so
|
||||
// BuildDynamicCommands will emit at least one cobra command.
|
||||
func minimalCLIServer(id, endpoint string) market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: id + "-key",
|
||||
DisplayName: id,
|
||||
Endpoint: endpoint,
|
||||
Source: "market",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"test_tool": {CLIName: "test"},
|
||||
},
|
||||
},
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork verifies that when a
|
||||
// fresh registry cache exists, no network request is made.
|
||||
//
|
||||
// This test uses an isolated DWS_CACHE_DIR + discoveryBaseURLOverride so that:
|
||||
// - useCache=true (DWS_CATALOG_FIXTURE is "")
|
||||
// - The test server records any incoming request; it should NOT be hit when cache is fresh.
|
||||
func TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("test-fresh"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Isolated cache dir with a FRESH snapshot.
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC(), // fresh
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
// Point discovery to the test server. Since cache is fresh and
|
||||
// useCache=true (CATALOG_FIXTURE is ""), the network should not be needed.
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("network request count = %d, want 0 (fresh cache should be used)", got)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Errorf("loadDynamicCommands() returned 0 commands, want >0 from fresh cache")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsUsesStaleCacheOnStartup verifies that when the
|
||||
// registry cache is stale, startup still returns commands from the cache
|
||||
// instead of blocking on a synchronous market refresh.
|
||||
func TestLoadDynamicCommandsUsesStaleCacheOnStartup(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Isolated cache dir with a STALE snapshot.
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // older than RegistryTTL=24h
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("stale", "https://mcp.dingtalk.com/stale/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("loadDynamicCommands() = 0 commands, want >0 from stale cache")
|
||||
}
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("startup network request count = %d, want 0 (stale cache should not block startup)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsCacheUpdatedAfterFetch verifies the cache is persisted
|
||||
// after a successful network fetch (useCache=true, isolated cache dir).
|
||||
func TestLoadDynamicCommandsCacheUpdatedAfterFetch(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("fresh-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL) // stale/empty cache → network
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
_ = loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
snapshot, freshness, err := store.LoadRegistry("default/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() after fetch error = %v", err)
|
||||
}
|
||||
if freshness != cache.FreshnessFresh {
|
||||
t.Errorf("cache freshness = %s, want fresh", freshness)
|
||||
}
|
||||
if len(snapshot.Servers) == 0 {
|
||||
t.Errorf("cache servers = 0, want >0 after network fetch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError verifies that
|
||||
// when the market API is unavailable but a stale cache exists, the CLI
|
||||
// still generates commands from the stale data (offline degradation).
|
||||
func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // stale
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("degraded", "https://mcp.dingtalk.com/degraded/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Errorf("loadDynamicCommands() = 0 commands, want >0 (stale fallback on network error)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
|
||||
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
|
||||
// This test requires background refresh which only runs in production mode.
|
||||
t.Skip("async revalidation disabled in test mode")
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
var phase atomic.Int32
|
||||
phase.Store(1)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
payload := marketListResponseForSpecs(testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "create_document",
|
||||
cliName: "create-document",
|
||||
})
|
||||
if phase.Load() == 2 {
|
||||
payload = marketListResponseForSpecs(
|
||||
testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "archive_document",
|
||||
cliName: "archive-document",
|
||||
},
|
||||
testCLIServerSpec{
|
||||
id: "drive",
|
||||
command: "drive",
|
||||
tool: "list_files",
|
||||
cliName: "list-files",
|
||||
},
|
||||
)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
|
||||
snapshot, _, err := store.LoadRegistry("default/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() error = %v", err)
|
||||
}
|
||||
snapshot.SavedAt = time.Now().UTC().Add(-2 * time.Hour)
|
||||
if err := store.SaveRegistry("default/default", snapshot); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
phase.Store(2)
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
refreshed, _, err := store.LoadRegistry("default/default")
|
||||
if err == nil && len(refreshed.Servers) == 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"archive-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
var phase atomic.Int32
|
||||
docDetailCalls := new(atomic.Int32)
|
||||
driveDetailCalls := new(atomic.Int32)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
},
|
||||
}
|
||||
if phase.Load() == 1 {
|
||||
payload["servers"] = []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-25T10:00:00Z", 1001, "archive_document", "archive-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
case r.URL.Path == "/mcp/market/detail":
|
||||
switch r.URL.Query().Get("mcpId") {
|
||||
case "1001":
|
||||
docDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1001, "archive_document", "Archive Document", "archive desc"))
|
||||
case "1002":
|
||||
driveDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
|
||||
default:
|
||||
http.Error(w, "unknown mcpId", http.StatusNotFound)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
|
||||
phase.Store(1)
|
||||
docDetailCalls.Store(0)
|
||||
driveDetailCalls.Store(0)
|
||||
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after aged startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after aged startup = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryTTLExpires(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
docDetailCalls := new(atomic.Int32)
|
||||
driveDetailCalls := new(atomic.Int32)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
},
|
||||
})
|
||||
case r.URL.Path == "/mcp/market/detail":
|
||||
switch r.URL.Query().Get("mcpId") {
|
||||
case "1001":
|
||||
docDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1001, "create_document", "Create Document", "create desc"))
|
||||
case "1002":
|
||||
driveDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
|
||||
default:
|
||||
http.Error(w, "unknown mcpId", http.StatusNotFound)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
|
||||
docDetailCalls.Store(0)
|
||||
driveDetailCalls.Store(0)
|
||||
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-25*time.Hour))
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after registry TTL expiry = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after registry TTL expiry = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsUsesStaleCacheWithoutBlockingRegistryRefresh(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponseForSpecs(testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "archive_document",
|
||||
cliName: "archive-document",
|
||||
}))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour),
|
||||
Servers: []market.ServerDescriptor{
|
||||
{
|
||||
Key: "doc-key",
|
||||
DisplayName: "doc",
|
||||
Endpoint: "https://mcp.dingtalk.com/doc/v1",
|
||||
Source: "market",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_document": {CLIName: "create-document"},
|
||||
},
|
||||
},
|
||||
HasCLIMeta: true,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
start := time.Now()
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("loadDynamicCommands() took %v, want stale cache startup under 200ms", elapsed)
|
||||
}
|
||||
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
}
|
||||
|
||||
// TestFetchDetailsByServerIDRunsConcurrently verifies that detail fetches are
|
||||
// concurrent, not serial. Uses MCPID path to avoid the localhost SSRF guard.
|
||||
func TestFetchDetailsByServerIDRunsConcurrently(t *testing.T) {
|
||||
const numServers = 4
|
||||
const delay = 50 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(delay)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"mcpId": 1, "name": "test", "description": "test",
|
||||
"tools": []any{
|
||||
map[string]any{"toolName": "test_tool", "toolTitle": "Test Tool", "toolDesc": "desc"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
servers := make([]market.ServerDescriptor, numServers)
|
||||
for i := range servers {
|
||||
servers[i] = market.ServerDescriptor{
|
||||
DetailLocator: market.DetailLocator{MCPID: i + 1},
|
||||
CLI: market.CLIOverlay{ID: "test-server-" + string(rune('a'+i))},
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, cache.NewStore(t.TempDir()), false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
serialBound := time.Duration(numServers) * delay
|
||||
if elapsed >= serialBound {
|
||||
t.Errorf("elapsed %v >= serial bound %v: requests appear serial, want concurrent", elapsed, serialBound)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Errorf("fetchDetailsByServerID() = empty map, want results")
|
||||
}
|
||||
}
|
||||
|
||||
func assertDynamicCommandChildren(t *testing.T, cmds []*cobra.Command, name string, want []string) {
|
||||
t.Helper()
|
||||
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Name() != name {
|
||||
continue
|
||||
}
|
||||
got := make([]string, 0)
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
got = append(got, child.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
|
||||
sortedWant := append([]string(nil), want...)
|
||||
sort.Strings(sortedWant)
|
||||
if len(got) != len(sortedWant) {
|
||||
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
|
||||
}
|
||||
for idx := range got {
|
||||
if got[idx] != sortedWant[idx] {
|
||||
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatalf("command %q not found", name)
|
||||
}
|
||||
|
||||
func registryServerEnvelope(id, command, updatedAt string, mcpID int, toolName, cliName string) map[string]any {
|
||||
return map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": command,
|
||||
"description": command + " desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + command + "/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active",
|
||||
"isLatest": true,
|
||||
"updatedAt": updatedAt,
|
||||
"publishedAt": updatedAt,
|
||||
"mcpId": mcpID,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": id,
|
||||
"command": command,
|
||||
"toolOverrides": map[string]any{
|
||||
toolName: map[string]any{
|
||||
"cliName": cliName,
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detailResponse(mcpID int, toolName, title, desc string) map[string]any {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"mcpId": mcpID,
|
||||
"name": title,
|
||||
"description": desc,
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"toolName": toolName,
|
||||
"toolTitle": title,
|
||||
"toolDesc": desc,
|
||||
"toolRequest": `{"type":"object"}`,
|
||||
"toolResponse": `{"type":"object"}`,
|
||||
"actionVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ageCacheSnapshotsOnDisk(t *testing.T, root string, savedAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := payload["saved_at"]; !ok {
|
||||
return nil
|
||||
}
|
||||
payload["saved_at"] = savedAt.Format(time.RFC3339Nano)
|
||||
|
||||
rewritten, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, rewritten, 0o644)
|
||||
})
|
||||
if walkErr != nil {
|
||||
t.Fatalf("ageCacheSnapshotsOnDisk() error = %v", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetailsByServerIDUsesCacheOnHit verifies that a fresh detail cache
|
||||
// entry prevents any network request.
|
||||
func TestFetchDetailsByServerIDUsesCacheOnHit(t *testing.T) {
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"tools": []any{}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cachedTools := []market.DetailTool{{ToolName: "cached_tool", ToolTitle: "Cached", ToolDesc: "from cache"}}
|
||||
cachedJSON, _ := json.Marshal(map[string]any{"tools": cachedTools})
|
||||
err := store.SaveDetail("default/default", "test-server", cache.DetailSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
MCPID: 42,
|
||||
Payload: cachedJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDetail() error = %v", err)
|
||||
}
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{DetailLocator: market.DetailLocator{MCPID: 42}, CLI: market.CLIOverlay{ID: "test-server"}, HasCLIMeta: true},
|
||||
}
|
||||
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, store, false)
|
||||
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("network request count = %d, want 0 (fresh detail cache should be used)", got)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Errorf("fetchDetailsByServerID() returned empty map, want cached tools")
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// captureStderr redirects os.Stderr for the duration of fn and returns what
|
||||
// was written to it.
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
pipeR, pipeW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
origStderr := os.Stderr
|
||||
os.Stderr = pipeW
|
||||
defer func() { os.Stderr = origStderr }()
|
||||
|
||||
fn()
|
||||
|
||||
_ = pipeW.Close()
|
||||
os.Stderr = origStderr
|
||||
captured, _ := io.ReadAll(pipeR)
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
|
||||
// hatch for a poisoned discovery cache: when the dynamic command build
|
||||
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
|
||||
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
|
||||
// the panic. With no on-disk cache to quarantine there is nothing to
|
||||
// self-heal from, so it degrades to the hardcoded helper commands and
|
||||
// prints a stderr hint pointing at `dws cache refresh`.
|
||||
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
|
||||
// path: when the build panics AND a discovery cache exists on disk, the
|
||||
// partition is quarantined (moved aside, kept for inspection) and the build
|
||||
// retried once. The retry succeeding means the user gets the full dynamic
|
||||
// command tree with zero manual cache surgery.
|
||||
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
partition := editionPartition()
|
||||
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
|
||||
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
if strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
|
||||
// safety net: if the rebuild after quarantine panics again (remote envelope
|
||||
// still poisoned, or offline), the CLI degrades to helper commands and keeps
|
||||
// the `dws cache refresh` hint.
|
||||
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
|
||||
// transparent on the happy path: commands returned by the dynamic build
|
||||
// still reach the caller unchanged.
|
||||
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
cmds := newLegacyPublicCommands(context.Background(), nil)
|
||||
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
|
||||
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
|
||||
// the dynamic one wins — envelopes remain the runtime authority for behaviour
|
||||
// they declare. The helper subtree must not slip in via
|
||||
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
|
||||
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
|
||||
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
|
||||
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
|
||||
dyn.AddCommand(dynTask)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
|
||||
hlp := &cobra.Command{Use: "todo", Short: "helper"}
|
||||
hlp.AddCommand(hlpTask)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
// The dynamic leaf must still be the one we find under the top-level name.
|
||||
var found *cobra.Command
|
||||
for _, c := range got[0].Commands() {
|
||||
if c.Name() == "task" {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found != dynTask {
|
||||
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
|
||||
// registers siblings the discovery envelope did NOT declare (e.g.
|
||||
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
|
||||
// envelope's `chat message send`), those helper-only leaves are grafted into
|
||||
// the dynamic subtree instead of being dropped. This is a regression guard:
|
||||
// prior to this fix, pickCommands silently dropped the entire helper subtree
|
||||
// whenever the top-level product name collided, which disappeared every
|
||||
// helper-only leaf the envelope didn't cover.
|
||||
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
|
||||
dynMessage := &cobra.Command{Use: "message"}
|
||||
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynMessage)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMessage := &cobra.Command{Use: "message"}
|
||||
hlpMessage.AddCommand(helperOnlyLeaf)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpMessage)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
var grafted *cobra.Command
|
||||
for _, child := range dynMessage.Commands() {
|
||||
if child.Name() == "send-by-bot" {
|
||||
grafted = child
|
||||
}
|
||||
}
|
||||
if grafted == nil {
|
||||
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
|
||||
}
|
||||
if grafted != helperOnlyLeaf {
|
||||
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
|
||||
// names are NOT in the dynamic set are preserved — the dynamic overlay only
|
||||
// shadows products it actually covers.
|
||||
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
attendanceHelper := &cobra.Command{Use: "attendance"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
names := make(map[string]*cobra.Command, len(got))
|
||||
for _, c := range got {
|
||||
names[c.Name()] = c
|
||||
}
|
||||
if names["todo"] != dyn {
|
||||
t.Fatalf("todo = %v, want dynamic", names["todo"])
|
||||
}
|
||||
if names["attendance"] != attendanceHelper {
|
||||
t.Fatalf("attendance not preserved from helpers")
|
||||
}
|
||||
if names["chat"] != chatHelper {
|
||||
t.Fatalf("chat not preserved from helpers")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
|
||||
// when discovery returns nothing, helpers are the sole source of truth — the
|
||||
// behaviour must be identical to the pre-refactor append-all code path.
|
||||
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, chatHelper}
|
||||
|
||||
got := pickCommands(nil, helpers)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2", len(got))
|
||||
}
|
||||
if got[0] != todoHelper || got[1] != chatHelper {
|
||||
t.Fatalf("pickCommands changed helpers order or identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
|
||||
// shape mismatch: the discovery envelope publishes `chat group members` as
|
||||
// a LEAF (the get_group_members tool exposed at that CLI path), while the
|
||||
// hardcoded helper has restructured `members` into a GROUP container with
|
||||
// `list / add / remove / add-bot` subcommands. The helper group carries the
|
||||
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
|
||||
// and surface its subtree — otherwise `dws chat group members list` is
|
||||
// unreachable and the user-visible regression in #164 stays.
|
||||
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
|
||||
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
|
||||
dynMembers.Flags().String("id", "", "")
|
||||
dynGroup := &cobra.Command{Use: "group"}
|
||||
dynGroup.AddCommand(dynMembers)
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynGroup)
|
||||
|
||||
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
hlpList.Flags().String("id", "", "")
|
||||
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
|
||||
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMembers := &cobra.Command{Use: "members"}
|
||||
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
|
||||
cobracmd.SetOverridePriority(hlpMembers, 100)
|
||||
hlpGroup := &cobra.Command{Use: "group"}
|
||||
hlpGroup.AddCommand(hlpMembers)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpGroup)
|
||||
|
||||
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("got %v, want [dyn]", got)
|
||||
}
|
||||
|
||||
// Locate the (potentially replaced) members node under chat.group.
|
||||
var members *cobra.Command
|
||||
for _, c := range dynGroup.Commands() {
|
||||
if c.Name() == "members" {
|
||||
members = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members node missing under dyn.chat.group after merge")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false}
|
||||
for _, sub := range members.Commands() {
|
||||
if _, ok := want[sub.Name()]; ok {
|
||||
want[sub.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` after merge, missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
|
||||
// a misbehaving factory.
|
||||
func TestPickCommands_NilsAreSkipped(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
|
||||
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
|
||||
}
|
||||
if got[0] != dyn || got[1] != hlp {
|
||||
t.Fatalf("unexpected ordering or identity after nil filter")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestEditionPartition_SingleSourceOfTruth is the regression test that
|
||||
// specifically targets the original bug: internal/app.loadDynamicCommands
|
||||
// was computing its partition one way (editionPartition() →
|
||||
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
|
||||
// config.DefaultPartition ("default/default"). This meant runtime endpoint
|
||||
// resolution and command-tree generation read different cache files, and
|
||||
// under gray-release the two partitions carried disjoint product lists —
|
||||
// the historical root cause of `dws conference meeting create` failing
|
||||
// while `dws todo task list` succeeded on the same host.
|
||||
//
|
||||
// Keeping both sides funneled through config.EditionPartition is the
|
||||
// central invariant the fix enforces. If this test ever regresses, the
|
||||
// two-partition split almost certainly came back.
|
||||
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"open edition falls through to default/default", "", config.DefaultPartition},
|
||||
{"explicit open edition remains default", "open", config.DefaultPartition},
|
||||
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
|
||||
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
edition.Override(&edition.Hooks{Name: tc.edition})
|
||||
legacy := editionPartition()
|
||||
shared := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
if legacy != shared {
|
||||
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
|
||||
}
|
||||
if legacy != tc.want {
|
||||
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_a"),
|
||||
authLogoutTestToken("corp_b"),
|
||||
)
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_b")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
Params: map[string]any{"limit": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
|
||||
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
|
||||
}
|
||||
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if content["multiProfile"] != true {
|
||||
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
|
||||
}
|
||||
if content["success"] != true {
|
||||
t.Fatalf("success = %#v, want true", content["success"])
|
||||
}
|
||||
profiles := content["profiles"].([]any)
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(profiles))
|
||||
}
|
||||
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
|
||||
entry := profiles[i].(map[string]any)
|
||||
if entry["corpId"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
|
||||
}
|
||||
resultPayload := entry["result"].(map[string]any)
|
||||
if resultPayload["runtimeProfile"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi {
|
||||
t.Fatal("multi = false, want true")
|
||||
}
|
||||
if len(selections) != 2 {
|
||||
t.Fatalf("selections len = %d, want 2", len(selections))
|
||||
}
|
||||
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
|
||||
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
|
||||
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a" {
|
||||
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == "corp_comma" {
|
||||
cfg.Profiles[i].Name = "alpha,beta"
|
||||
}
|
||||
}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if multi {
|
||||
t.Fatalf("multi = true, want false; selections=%#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
|
||||
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
|
||||
if err == nil {
|
||||
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("error = %q, want empty profile selector", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type multiProfileFallbackRunner struct{}
|
||||
|
||||
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"runtimeProfile": authpkg.RuntimeProfile(),
|
||||
"tool": invocation.Tool,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMultiSkillSrc creates a fake multi skill source tree with the given
|
||||
// subdir names, each containing a minimal SKILL.md.
|
||||
func writeMultiSkillSrc(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
src := t.TempDir()
|
||||
for _, n := range names {
|
||||
dir := filepath.Join(src, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
|
||||
// instance is safe for goroutines saving tool snapshots concurrently, as long
|
||||
// as each goroutine targets a distinct (partition, serverKey). This mirrors
|
||||
// the real plugin discovery path where each goroutine owns one plugin/server.
|
||||
//
|
||||
// Each call serializes to its own "<key>.json.tmp" file followed by a
|
||||
// rename(2) to the final path, so concurrent writers targeting distinct keys
|
||||
// never collide. The invariant asserted here: after N parallel writes, the
|
||||
// Store returns each written snapshot intact under LoadTools.
|
||||
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
|
||||
const (
|
||||
partition = "default/default"
|
||||
writers = 16
|
||||
)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", idx)
|
||||
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
|
||||
ServerKey: key,
|
||||
}); err != nil {
|
||||
t.Errorf("SaveTools(%s): %v", key, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", i)
|
||||
snapshot, _, err := store.LoadTools(partition, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools(%s): %v", key, err)
|
||||
}
|
||||
if snapshot.ServerKey != key {
|
||||
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
|
||||
// write path by spraying distinct server descriptors in parallel. Afterwards
|
||||
// every injected product ID must be resolvable — a missing entry would
|
||||
// indicate a lost write through an un-synchronized map update.
|
||||
func TestAppendDynamicServerConcurrent(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("plugin-id-%d", idx)
|
||||
endpoint := fmt.Sprintf("https://example.test/%d", idx)
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("plugin-id-%d", i)
|
||||
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
|
||||
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
|
||||
// survives concurrent writers — every registered client must be looked up
|
||||
// afterwards. Uses nil client pointers since LookupStdioClient only compares
|
||||
// keys, not values.
|
||||
func TestRegisterStdioClientConcurrent(t *testing.T) {
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
key := fmt.Sprintf("plugin/%d", i)
|
||||
if _, ok := LookupStdioClient(key); !ok {
|
||||
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePluginColdTimeouts covers the three code paths of the env
|
||||
// parser: unset (defaults), valid duration (applied to all three slots),
|
||||
// and invalid duration (logged and ignored, defaults returned).
|
||||
func TestResolvePluginColdTimeouts(t *testing.T) {
|
||||
t.Run("defaults when env unset", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
|
||||
}
|
||||
if got.httpAuth != 1500*time.Millisecond {
|
||||
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
|
||||
}
|
||||
if got.stdio != 2*time.Second {
|
||||
t.Errorf("stdio = %v, want 2s", got.stdio)
|
||||
}
|
||||
})
|
||||
t.Run("env override applies to all slots", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
|
||||
got := resolvePluginColdTimeouts()
|
||||
want := 3500 * time.Millisecond
|
||||
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
|
||||
t.Errorf("override not propagated: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("invalid env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
|
||||
t.Errorf("invalid env should not override defaults: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "0")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,13 +19,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"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/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -38,9 +34,9 @@ import (
|
||||
//
|
||||
// When no CLI metadata is present, a minimal overlay keyed by the server
|
||||
// name is returned so callers can still build an identity descriptor.
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
overlay := mcptypes.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
@@ -77,55 +73,26 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.C
|
||||
return overlay
|
||||
}
|
||||
|
||||
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
|
||||
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
|
||||
// Returns nil if tools is empty.
|
||||
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
detailTools := make([]market.DetailTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
return map[string][]market.DetailTool{overlayID: detailTools}
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json — no subprocess required.
|
||||
// server using only its manifest + overlay.json.
|
||||
//
|
||||
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
|
||||
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
|
||||
// registration (legacy path).
|
||||
//
|
||||
// When a warm tools cache exists for this server, its DetailTools are passed
|
||||
// to BuildDynamicCommands so flag types are enriched from the last successful
|
||||
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
|
||||
// only; the next startup after a successful refresh picks up the full schema.
|
||||
// Dynamic command building has been removed; this now simply registers the
|
||||
// server descriptor and returns nil commands.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
store *cache.Store,
|
||||
) ([]*cobra.Command, market.ServerDescriptor, bool) {
|
||||
) ([]*cobra.Command, mcptypes.ServerDescriptor, bool) {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, market.ServerDescriptor{}, false
|
||||
return nil, mcptypes.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
@@ -138,69 +105,11 @@ func registerStdioServerFromOverlay(
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
// Warm-cache enrichment: if a prior successful discovery wrote a
|
||||
// non-empty tool list, use its schema to enrich flag types.
|
||||
var detailsByID map[string][]market.DetailTool
|
||||
if store != nil {
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
|
||||
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides),
|
||||
"commands", len(cmds),
|
||||
"enriched", detailsByID != nil)
|
||||
"toolOverrides", len(overlay.ToolOverrides))
|
||||
|
||||
return cmds, descriptor, true
|
||||
}
|
||||
|
||||
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
|
||||
// subprocess and persists the result so the next startup can enrich
|
||||
// overlay-registered commands with typed flags. It never constructs cobra
|
||||
// commands; command registration has already happened synchronously from
|
||||
// the overlay before this function runs.
|
||||
//
|
||||
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
|
||||
// SaveTools entirely so a transient error cannot poison the warm cache
|
||||
// with a null-tools snapshot.
|
||||
func refreshStdioToolsCache(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
store *cache.Store,
|
||||
timeouts pluginColdTimeouts,
|
||||
) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return
|
||||
}
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
}); err != nil {
|
||||
slog.Warn("plugin: failed to persist stdio tools cache",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("plugin: stdio tools cache refreshed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
|
||||
}
|
||||
|
||||
// hasOverlayToolOverrides reports whether a stdio plugin server carries
|
||||
// enough CLI metadata to be registered via the overlay-first path. Used by
|
||||
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
|
||||
// buckets without doing the overlay parse twice.
|
||||
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
|
||||
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
|
||||
// Dynamic command tree building has been removed.
|
||||
_ = runner
|
||||
return nil, descriptor, true
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// withCleanStdioRegistry snapshots and restores the package-level stdio
|
||||
// client registry so tests that call RegisterStdioClient don't leak state
|
||||
// across cases.
|
||||
func withCleanStdioRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
|
||||
// CLIOverlay with the given tool-override map. The stdio client is created
|
||||
// but never started, since the overlay-first path does not require the
|
||||
// subprocess to be running for command registration.
|
||||
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal overlay: %v", err)
|
||||
}
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{
|
||||
Name: pluginName,
|
||||
Version: "1.0.0",
|
||||
Description: pluginName + " plugin",
|
||||
MCPServers: map[string]*plugin.MCPServer{
|
||||
serverKey: {
|
||||
Type: "stdio",
|
||||
Command: "/usr/bin/true", // never executed by overlay-first path
|
||||
CLI: raw,
|
||||
},
|
||||
},
|
||||
},
|
||||
Root: t.TempDir(),
|
||||
}
|
||||
sc := plugin.StdioServerClient{
|
||||
Key: serverKey,
|
||||
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
|
||||
}
|
||||
return p, sc
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
|
||||
// the core promise of the overlay-first path: when overlay.json ships
|
||||
// ToolOverrides, commands appear immediately — no subprocess probe.
|
||||
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
Groups: map[string]market.CLIGroupDef{
|
||||
"meeting": {Description: "会议控制"},
|
||||
"member": {Description: "成员管理"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
|
||||
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
|
||||
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
|
||||
}
|
||||
|
||||
var root *struct{ name, path string }
|
||||
_ = root
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "conference-local" {
|
||||
found = true
|
||||
// Groups must be attached as sub-commands.
|
||||
groups := map[string]bool{}
|
||||
for _, sub := range c.Commands() {
|
||||
groups[sub.Name()] = true
|
||||
}
|
||||
if !groups["meeting"] {
|
||||
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
|
||||
}
|
||||
if !groups["member"] {
|
||||
t.Errorf("missing 'member' group sub-command, children = %v", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
names := []string{}
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
|
||||
}
|
||||
|
||||
// AppendDynamicServer registration: product ID should land in
|
||||
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
|
||||
// visible even under a restrictive VisibleProducts hook.
|
||||
if !DirectRuntimeProductIDs()["conference-local"] {
|
||||
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
|
||||
}
|
||||
|
||||
// RegisterStdioClient side-effect: the runtime must be able to look up
|
||||
// the StdioClient when the endpoint is invoked later.
|
||||
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
|
||||
t.Error("LookupStdioClient missing conference-local/conference-local")
|
||||
}
|
||||
|
||||
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
|
||||
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
|
||||
// tools cache with a schema-bearing DetailTool and asserts the resulting
|
||||
// leaf command picks up the typed flag derived from InputSchema.
|
||||
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "cache-plugin",
|
||||
Command: "cache-plugin",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"echo": {CLIName: "echo", Description: "Echo input"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo the input",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"message": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools: %v", err)
|
||||
}
|
||||
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok || len(cmds) == 0 {
|
||||
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
|
||||
}
|
||||
|
||||
var echoLeaf *leafMatch
|
||||
for _, top := range cmds {
|
||||
if top.Name() != "cache-plugin" {
|
||||
continue
|
||||
}
|
||||
for _, sub := range top.Commands() {
|
||||
if sub.Name() == "echo" {
|
||||
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
if echoLeaf == nil {
|
||||
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
|
||||
}
|
||||
if !echoLeaf.hasFlag {
|
||||
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
|
||||
}
|
||||
}
|
||||
|
||||
type leafMatch struct {
|
||||
name string
|
||||
hasFlag bool
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
|
||||
// asserts the fallback contract: when overlay.json is missing toolOverrides,
|
||||
// the overlay-first path declines so the caller can route the entry through
|
||||
// the legacy discovery-first registerStdioServer.
|
||||
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
|
||||
// on runtime discovery for its tool list).
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "legacy-plugin",
|
||||
Command: "legacy-plugin",
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if ok {
|
||||
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
|
||||
}
|
||||
if cmds != nil {
|
||||
t.Errorf("cmds = %v, want nil", cmds)
|
||||
}
|
||||
if DirectRuntimeProductIDs()["legacy-plugin"] {
|
||||
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
|
||||
}
|
||||
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
|
||||
t.Error("stdio client must NOT be registered in fallback case")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
|
||||
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
|
||||
// timeout, empty tool list), the existing warm cache must remain intact so
|
||||
// the next startup still enriches flags from the last good snapshot.
|
||||
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
|
||||
ID: "refresh-plugin",
|
||||
Command: "refresh-plugin",
|
||||
})
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
goodSnapshot := cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "ping",
|
||||
Description: "Health check",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
|
||||
t.Fatalf("seed SaveTools: %v", err)
|
||||
}
|
||||
|
||||
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
|
||||
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
|
||||
// a no-op and must NOT overwrite the good cache with a null snapshot.
|
||||
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
|
||||
|
||||
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools after failed refresh: %v", err)
|
||||
}
|
||||
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
|
||||
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
|
||||
// test for the loadPlugins split decision: stdio plugins whose overlay ships
|
||||
// ToolOverrides must have their commands visible on the root immediately,
|
||||
// WITHOUT waiting on any discovery handshake. It drives the same sequence
|
||||
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
|
||||
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
|
||||
// visibility filter even when no discovery has run.
|
||||
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Simulate a wukong-like edition that declares a static VisibleProducts
|
||||
// whitelist NOT containing our plugin. This is the exact scenario where
|
||||
// the original bug surfaced.
|
||||
overrideVisibleProducts(t, []string{"calendar", "doc"})
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
// No discovery runs — no cache seeded. This mirrors a cold-start where
|
||||
// the subprocess is unavailable (or just slow) yet the user expects
|
||||
// `dws --help` to still list the plugin.
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false")
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// Also add a sibling command that is NOT a registered product so we can
|
||||
// prove the visibility filter still hides non-product commands.
|
||||
bogus := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(bogus)
|
||||
for _, c := range cmds {
|
||||
root.AddCommand(c)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
var pluginCmd *cobra.Command
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == "conference-local" {
|
||||
pluginCmd = c
|
||||
}
|
||||
}
|
||||
if pluginCmd == nil {
|
||||
t.Fatal("conference-local missing from root after overlay-first registration")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
|
||||
}
|
||||
if !bogus.Hidden {
|
||||
t.Error("bogus-not-a-product must be hidden by the visibility filter")
|
||||
}
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasOverlayToolOverrides exercises the split-decision helper used by
|
||||
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
|
||||
func TestHasOverlayToolOverrides(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
overlay market.CLIOverlay
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty overlay",
|
||||
overlay: market.CLIOverlay{ID: "x", Command: "x"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "overlay with overrides",
|
||||
overlay: market.CLIOverlay{
|
||||
ID: "x",
|
||||
Command: "x",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"foo": {CLIName: "foo"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
|
||||
got := hasOverlayToolOverrides(p, sc)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "root profile before command",
|
||||
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "profile after leaf command",
|
||||
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
|
||||
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
|
||||
},
|
||||
{
|
||||
name: "equals form",
|
||||
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "three profiles",
|
||||
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "already quoted by shell remains unchanged",
|
||||
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "single profile remains unchanged",
|
||||
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "trailing comma before next flag remains validation input",
|
||||
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, _ := normalizeProfileFlagArgs(tc.args)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
|
||||
if got != "corpA,corpB" {
|
||||
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
|
||||
restore := normalizeProcessProfileArgs()
|
||||
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
restore()
|
||||
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newProfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "组织 profile 管理",
|
||||
Long: `管理本机已登录的钉钉组织 profile。
|
||||
|
||||
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
|
||||
profile switch/use 才会持久修改默认组织上下文。`,
|
||||
Example: ` dws profile list
|
||||
dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出已登录组织 profile",
|
||||
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
|
||||
Example: ` dws profile list
|
||||
dws profile list --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
|
||||
}
|
||||
writeProfileListTable(cmd.OutOrStdout(), cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newProfileUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [name|corpId|-]",
|
||||
Short: "切换当前组织 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
|
||||
Example: ` dws profile use <corpId>
|
||||
dws profile use --name "钉钉"
|
||||
dws profile use -`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileSwitchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "switch [name|corpId|-]",
|
||||
Short: "切换当前组织 profile",
|
||||
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
|
||||
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
|
||||
需要只影响单次业务命令时,请使用全局 --profile。`,
|
||||
Example: ` dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch --corpId <corpId>
|
||||
dws profile switch --name "钉钉"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
|
||||
_ = cmd.Flags().MarkHidden("corp-id")
|
||||
_ = cmd.Flags().MarkHidden("corpid")
|
||||
_ = cmd.Flags().MarkHidden("corp")
|
||||
}
|
||||
|
||||
var (
|
||||
profileSwitchSelector = selectProfileSwitchProfile
|
||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
const (
|
||||
profileSwitchVisibleOptions = 5
|
||||
profileSwitchCellPadding = 1
|
||||
profileSwitchOrgWidth = 34
|
||||
profileSwitchStatusWidth = 10
|
||||
)
|
||||
|
||||
var profileSwitchRenderer = newProfileSwitchRenderer()
|
||||
|
||||
func newProfileSwitchRenderer() *lipgloss.Renderer {
|
||||
renderer := lipgloss.NewRenderer(io.Discard)
|
||||
renderer.SetColorProfile(termenv.TrueColor)
|
||||
renderer.SetHasDarkBackground(true)
|
||||
return renderer
|
||||
}
|
||||
|
||||
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
selector, err := profileSwitchSelectorFromCommand(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI := false
|
||||
if selector == "" {
|
||||
selector, err = profileSwitchSelector(cmd, configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI = true
|
||||
}
|
||||
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
|
||||
}
|
||||
|
||||
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
|
||||
selectors := make([]string, 0, 2)
|
||||
if len(args) > 0 {
|
||||
selectors = append(selectors, strings.TrimSpace(args[0]))
|
||||
}
|
||||
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
|
||||
value, changed := changedStringFlag(cmd, name)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
|
||||
}
|
||||
selectors = append(selectors, value)
|
||||
}
|
||||
if len(selectors) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
selector := selectors[0]
|
||||
for _, candidate := range selectors[1:] {
|
||||
if candidate != selector {
|
||||
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
|
||||
}
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
|
||||
if cmd == nil || cmd.Flags() == nil {
|
||||
return "", false
|
||||
}
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Changed {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(flag.Value.String()), true
|
||||
}
|
||||
|
||||
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
|
||||
var (
|
||||
profile *authpkg.Profile
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(selector) == "-" {
|
||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
||||
} else {
|
||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||
}
|
||||
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
|
||||
if !profileSwitchInteractiveTerminal() {
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||
}
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = strings.TrimSpace(cfg.PrimaryProfile)
|
||||
}
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
}
|
||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
||||
}
|
||||
|
||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.ErrOrStderr()),
|
||||
tea.WithContext(cmd.Context()),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
|
||||
}
|
||||
final, ok := finalModel.(profileSwitchTUIModel)
|
||||
if !ok || final.aborted || !final.submitted {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return final.selectedCorpID(), nil
|
||||
}
|
||||
|
||||
type profileSwitchTUIModel struct {
|
||||
cfg *authpkg.ProfilesConfig
|
||||
profiles []authpkg.Profile
|
||||
selected int
|
||||
offset int
|
||||
submitted bool
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
|
||||
model := profileSwitchTUIModel{cfg: cfg}
|
||||
if cfg != nil {
|
||||
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
}
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
model.ensureSelectedVisible()
|
||||
return model
|
||||
}
|
||||
|
||||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||||
sorted := append([]authpkg.Profile(nil), profiles...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left, leftOK := profileSwitchSortTime(sorted[i])
|
||||
right, rightOK := profileSwitchSortTime(sorted[j])
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return false
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
|
||||
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
|
||||
if t, ok := parseProfileSwitchTime(raw); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseProfileSwitchTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.profiles)-1 {
|
||||
m.selected++
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "enter":
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) View() string {
|
||||
var b strings.Builder
|
||||
title := profileSwitchTitleStyle().Render("选择要切换的组织")
|
||||
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(hint)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.tableView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) tableView() string {
|
||||
rows := []string{
|
||||
profileSwitchBorder("┌", "┬", "┐"),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchBorder("├", "┼", "┤"),
|
||||
}
|
||||
for i := 0; i < profileSwitchVisibleOptions; i++ {
|
||||
idx := m.offset + i
|
||||
if idx >= 0 && idx < len(m.profiles) {
|
||||
rows = append(rows, m.profileRow(idx))
|
||||
continue
|
||||
}
|
||||
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
|
||||
}
|
||||
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) profileRow(idx int) string {
|
||||
profile := m.profiles[idx]
|
||||
org, status := profileSwitchProfileCells(profile, m.cfg)
|
||||
style := profileSwitchNormalRowStyle()
|
||||
if idx == m.selected {
|
||||
org = "› " + org
|
||||
style = profileSwitchSelectedRowStyle()
|
||||
} else {
|
||||
org = " " + org
|
||||
}
|
||||
return profileSwitchStyledTableLine(org, status, style)
|
||||
}
|
||||
|
||||
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
|
||||
if len(m.profiles) == 0 {
|
||||
m.selected = 0
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
if m.selected < 0 {
|
||||
m.selected = 0
|
||||
}
|
||||
if m.selected >= len(m.profiles) {
|
||||
m.selected = len(m.profiles) - 1
|
||||
}
|
||||
if m.selected < m.offset {
|
||||
m.offset = m.selected
|
||||
}
|
||||
if m.selected >= m.offset+profileSwitchVisibleOptions {
|
||||
m.offset = m.selected - profileSwitchVisibleOptions + 1
|
||||
}
|
||||
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
if m.offset < 0 {
|
||||
m.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.profiles[m.selected].CorpID)
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
org, status := profileSwitchProfileCells(p, cfg)
|
||||
if status == "" {
|
||||
return org
|
||||
}
|
||||
return strings.Join([]string{org, status}, " | ")
|
||||
}
|
||||
|
||||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||||
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && p.CorpID == cfg.CurrentProfile {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileSwitchBorder(left, sep, right string) string {
|
||||
segments := []string{
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
|
||||
}
|
||||
|
||||
func profileSwitchTableLine(org, status string) string {
|
||||
cells := []string{
|
||||
profileSwitchTableCell(org, profileSwitchOrgWidth),
|
||||
profileSwitchTableCell(status, profileSwitchStatusWidth),
|
||||
}
|
||||
return "│" + strings.Join(cells, "│") + "│"
|
||||
}
|
||||
|
||||
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
|
||||
cells := []string{
|
||||
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
|
||||
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
|
||||
}
|
||||
|
||||
func profileSwitchTableSeparator() string {
|
||||
return profileSwitchBorderStyle().Render("│")
|
||||
}
|
||||
|
||||
func profileSwitchTableCell(value string, width int) string {
|
||||
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
|
||||
padding := strings.Repeat(" ", profileSwitchCellPadding)
|
||||
return padding + padProfileDisplayCell(clipped, width) + padding
|
||||
}
|
||||
|
||||
func padProfileDisplayCell(value string, width int) string {
|
||||
padding := width - lipgloss.Width(value)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
return value + strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
func profileSwitchCellWidth(contentWidth int) int {
|
||||
return contentWidth + profileSwitchCellPadding*2
|
||||
}
|
||||
|
||||
func profileSwitchSelectedRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchNormalRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
|
||||
}
|
||||
|
||||
func profileSwitchHeaderStyle() lipgloss.Style {
|
||||
return profileSwitchMutedStyle().Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchBorderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
|
||||
}
|
||||
|
||||
func profileSwitchTitleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
|
||||
}
|
||||
|
||||
type profileListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []profileView `json:"profiles"`
|
||||
}
|
||||
|
||||
type profileUseResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Profile profileView `json:"profile"`
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileListResponse{
|
||||
Success: true,
|
||||
PrimaryProfile: cfg.PrimaryProfile,
|
||||
CurrentProfile: cfg.CurrentProfile,
|
||||
PreviousProfile: cfg.PreviousProfile,
|
||||
Profiles: profileViews(cfg),
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileUseResponse{Success: true}
|
||||
if profile != nil {
|
||||
primaryProfile := ""
|
||||
currentProfile := ""
|
||||
if cfg != nil {
|
||||
primaryProfile = cfg.PrimaryProfile
|
||||
currentProfile = cfg.CurrentProfile
|
||||
}
|
||||
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
fmt.Fprintln(w, "未找到已登录 profile")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
current := ""
|
||||
if p.CorpID == cfg.CurrentProfile {
|
||||
current = "*"
|
||||
}
|
||||
primary := ""
|
||||
if p.CorpID == cfg.PrimaryProfile {
|
||||
primary = "*"
|
||||
}
|
||||
user := p.UserName
|
||||
if user == "" {
|
||||
user = p.UserID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%-3s %-3s %-28s %-34s %-10s %s\n",
|
||||
current,
|
||||
primary,
|
||||
clipProfileCell(profileOrgName(p), 28),
|
||||
clipProfileCell(p.CorpID, 34),
|
||||
status,
|
||||
user,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func profileUseMessage(profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return "[OK] 当前 profile 已切换"
|
||||
}
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
orgName := strings.TrimSpace(profile.CorpName)
|
||||
if orgName == "" {
|
||||
orgName = profileOrgName(*profile)
|
||||
}
|
||||
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
|
||||
}
|
||||
|
||||
func profileOrgName(p authpkg.Profile) string {
|
||||
if v := strings.TrimSpace(p.CorpName); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
|
||||
return profileView{
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
UserName: p.UserName,
|
||||
ClientID: p.ClientID,
|
||||
Status: p.Status,
|
||||
AuthorizedDomains: p.AuthorizedDomains,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: p.CorpID == primaryProfile,
|
||||
IsCurrent: p.CorpID == currentProfile,
|
||||
}
|
||||
}
|
||||
|
||||
func clipProfileCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return string(runes[:limit-3]) + "..."
|
||||
}
|
||||
|
||||
func clipProfileDisplayCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(value) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if lipgloss.Width(b.String())+rw > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
target := limit - 3
|
||||
var b strings.Builder
|
||||
width := 0
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if width+rw > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
width += rw
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
|
||||
profile := &authpkg.Profile{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
|
||||
t.Fatalf("writeProfileUseJSON() error = %v", err)
|
||||
}
|
||||
var resp profileUseResponse
|
||||
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
|
||||
}
|
||||
if resp.Profile.CorpName != "B Org" {
|
||||
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
|
||||
}
|
||||
if !resp.Profile.IsCurrent {
|
||||
t.Fatalf("isCurrent = false, want true")
|
||||
}
|
||||
if resp.Profile.IsPrimary {
|
||||
t.Fatalf("isPrimary = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false, want true")
|
||||
}
|
||||
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
|
||||
}
|
||||
for _, p := range resp.Profiles {
|
||||
if p.CorpName == "" {
|
||||
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||||
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_secondary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
|
||||
t.Fatalf("error = %v, want conflicting selector validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
called := false
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
called = true
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("profile switch without args did not invoke TUI selector")
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_primary",
|
||||
CurrentProfile: "corp_secondary",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
CorpID: "corp_primary",
|
||||
CorpName: "第一组织",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
CorpID: "corp_secondary",
|
||||
CorpName: "第二组织",
|
||||
UserName: "bob",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
|
||||
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
|
||||
for _, label := range []string{primary, current} {
|
||||
if strings.Contains(label, "\n") {
|
||||
t.Fatalf("profile switch label contains newline: %q", label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(primary, "第一组织") {
|
||||
t.Fatalf("primary option missing organization name: %q", primary)
|
||||
}
|
||||
if !strings.Contains(current, "当前组织") {
|
||||
t.Fatalf("current option missing current marker: %q", current)
|
||||
}
|
||||
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
|
||||
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
|
||||
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(2)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
view := model.tableView()
|
||||
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
|
||||
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
|
||||
}
|
||||
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
|
||||
if strings.Contains(view, unwanted) {
|
||||
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
|
||||
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
if got := strings.Count(view, profile.CorpID); got != 0 {
|
||||
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "old",
|
||||
CurrentProfile: "old",
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
|
||||
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
|
||||
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
|
||||
},
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, "old")
|
||||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||||
wantOrder := []string{"new", "fallback", "old"}
|
||||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != "old" {
|
||||
t.Fatalf("selectedCorpID = %q, want old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(7)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
for step := 0; step < 6; step++ {
|
||||
view := model.tableView()
|
||||
if got := strings.Count(view, "›"); got != 1 {
|
||||
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
name := profileOrgName(profile)
|
||||
if got := strings.Count(view, name); got > 1 {
|
||||
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
|
||||
}
|
||||
}
|
||||
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = next.(profileSwitchTUIModel)
|
||||
}
|
||||
if model.selected != 6 || model.offset != 2 {
|
||||
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
|
||||
rows := []string{
|
||||
profileSwitchTableLine("组织名", "本地状态"),
|
||||
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
|
||||
profileSwitchTableLine(" ACME", ""),
|
||||
profileSwitchTableLine("", ""),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
|
||||
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
|
||||
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
|
||||
}
|
||||
wantWidth := lipgloss.Width(rows[0])
|
||||
for i, row := range rows {
|
||||
if got := lipgloss.Width(row); got != wantWidth {
|
||||
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
|
||||
}
|
||||
if got := strings.Count(row, "│"); got != 3 {
|
||||
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
|
||||
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: corpID,
|
||||
CurrentProfile: corpID,
|
||||
}
|
||||
label := profileSwitchOptionLabel(authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: "钉钉",
|
||||
}, cfg)
|
||||
for _, want := range []string{"钉钉", "当前组织"} {
|
||||
if !strings.Contains(label, want) {
|
||||
t.Fatalf("profile switch label missing %q in %q", want, label)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
|
||||
if strings.Contains(label, unwanted) {
|
||||
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_00",
|
||||
CurrentProfile: "corp_00",
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
corpID := fmt.Sprintf("corp_%02d", i)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: fmt.Sprintf("组织%02d", i),
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "switch"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "use"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
profileSwitchInteractiveTerminal = oldInteractive
|
||||
})
|
||||
profileSwitchInteractiveTerminal = func() bool { return false }
|
||||
|
||||
_, err := selectProfileSwitchProfile(nil, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
|
||||
t.Fatalf("error = %v, want profile selector hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "corp_a",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B 组织",
|
||||
UserID: "bob-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeProfileListTable(&buf, cfg)
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"ORG_NAME",
|
||||
"钉钉(中国)信息技术有限公司",
|
||||
"B 组织",
|
||||
"corp_a",
|
||||
"corp_b",
|
||||
} {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(want)) {
|
||||
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
|
||||
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
|
||||
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
|
||||
got := profileUseMessage(&authpkg.Profile{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "ding8196",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
})
|
||||
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
|
||||
if !bytes.Contains([]byte(got), []byte(want)) {
|
||||
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
|
||||
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
|
||||
// IPv4-forcing client used by the legacy registry / discovery path. The
|
||||
// custom Transport overrides DialContext to force IPv4 — without an
|
||||
// explicit Proxy field it would also drop env-var proxy support.
|
||||
//
|
||||
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
|
||||
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
|
||||
// tests that read proxy env early would make this flaky. Asserting that the
|
||||
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
|
||||
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
|
||||
// at process boot.
|
||||
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := ipv4HTTPClient(5 * time.Second)
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_test",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-1",
|
||||
},
|
||||
Replay: recovery.Replay{
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
ToolArgs: map[string]any{"instanceId": "ins_1"},
|
||||
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery plan) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"event_id": "evt_test"`) {
|
||||
t.Fatalf("output missing event id:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"category": "resource"`) {
|
||||
t.Fatalf("output missing resource category:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_exec",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-2",
|
||||
},
|
||||
Replay: recovery.Replay{
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
ToolArgs: map[string]any{"instanceId": "ins_1"},
|
||||
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery execute) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"event_id": "evt_exec"`) {
|
||||
t.Fatalf("output missing event id:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"status": "needs_agent_action"`) {
|
||||
t.Fatalf("output missing bundle status:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"recovery", "finalize"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute(recovery finalize) error = nil, want validation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--event-id") {
|
||||
t.Fatalf("error = %v, want event-id requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_conflict",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-conflict",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute(recovery plan) error = nil, want conflict validation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") {
|
||||
t.Fatalf("error = %v, want mutually exclusive flags", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_legacy_finalize",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationUnknown,
|
||||
RawError: "unexpected upstream failure",
|
||||
Fingerprint: "fp-legacy-finalize",
|
||||
},
|
||||
})
|
||||
|
||||
executionPath := filepath.Join(configDir, "legacy_execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy execution) error = %v", err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{
|
||||
"recovery", "finalize",
|
||||
"--event-id", "evt_legacy_finalize",
|
||||
"--outcome", "failed",
|
||||
"--execution-file", executionPath,
|
||||
"-f", "json",
|
||||
})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery finalize) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"execution_recorded": true`) {
|
||||
t.Fatalf("output missing execution_recorded flag:\n%s", out.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
lastLine := lines[len(lines)-1]
|
||||
if !strings.Contains(lastLine, `"phase":"finalized"`) {
|
||||
t.Fatalf("expected finalized event, got %s", lastLine)
|
||||
}
|
||||
if !strings.Contains(lastLine, `"legacy_execution_file"`) {
|
||||
t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req["method"] {
|
||||
case "initialize":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
||||
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
|
||||
},
|
||||
})
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "tools/list":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"tools": []map[string]any{
|
||||
{
|
||||
"name": "search_documents",
|
||||
"title": "Search",
|
||||
"description": "Search documents",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "tools/call":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "baseId is required",
|
||||
},
|
||||
},
|
||||
"isError": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
|
||||
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe(stdout) error = %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe(stderr) error = %v", err)
|
||||
}
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
}()
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
|
||||
exitCode := Execute()
|
||||
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
stdoutData, _ := io.ReadAll(stdoutR)
|
||||
stderrData, _ := io.ReadAll(stderrR)
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData)
|
||||
}
|
||||
if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") {
|
||||
t.Fatalf("stderr missing recovery event id:\n%s", stderrData)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(last_error.json) error = %v", err)
|
||||
}
|
||||
var last recovery.LastError
|
||||
if err := json.Unmarshal(data, &last); err != nil {
|
||||
t.Fatalf("json.Unmarshal(last_error) error = %v", err)
|
||||
}
|
||||
if last.EventID == "" || last.Context.ToolName != "search_documents" {
|
||||
t.Fatalf("unexpected recovery snapshot %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) {
|
||||
t.Helper()
|
||||
|
||||
recoveryDir := filepath.Join(configDir, "recovery")
|
||||
if err := os.MkdirAll(recoveryDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(recovery) error = %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(last, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("json.MarshalIndent() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(last_error.json) error = %v", err)
|
||||
}
|
||||
}
|
||||
+165
-687
File diff suppressed because it is too large
Load Diff
@@ -1,254 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var skippedRuntimeCalls atomic.Int32
|
||||
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Active Service",
|
||||
Remotes: []market.RegistryRemote{
|
||||
{Type: "streamable-http", URL: srv.URL + "/mcp/active"},
|
||||
},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "active", Command: "active"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Skipped Service",
|
||||
Remotes: []market.RegistryRemote{
|
||||
{Type: "streamable-http", URL: srv.URL + "/mcp/skipped"},
|
||||
},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/mcp/active":
|
||||
http.Error(w, "active runtime unavailable", http.StatusInternalServerError)
|
||||
case "/mcp/skipped":
|
||||
skippedRuntimeCalls.Add(1)
|
||||
http.Error(w, "skipped runtime should not be called", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := cache.NewStore(cacheDir)
|
||||
const partition = "default/default"
|
||||
activeKey := market.ServerKey(srv.URL + "/mcp/active")
|
||||
skippedKey := market.ServerKey(srv.URL + "/mcp/skipped")
|
||||
|
||||
saveCachedRuntimeAndDetail(t, store, partition, activeKey)
|
||||
saveCachedRuntimeAndDetail(t, store, partition, skippedKey)
|
||||
saveCLIIDDetail(t, store, partition, "active")
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmd := newCacheCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"refresh"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := store.LoadTools(partition, activeKey); err == nil {
|
||||
t.Fatal("LoadTools(active) error = nil, want cache cleared before failed refresh")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, activeKey); err == nil {
|
||||
t.Fatal("LoadDetail(active) error = nil, want detail cache cleared before failed refresh")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, "active"); err != nil {
|
||||
t.Fatalf("LoadDetail(active CLI.ID) error = %v, want CLI metadata preserved on failed refresh", err)
|
||||
}
|
||||
if _, _, err := store.LoadTools(partition, skippedKey); err == nil {
|
||||
t.Fatal("LoadTools(skipped) error = nil, want skipped service cache removed")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, skippedKey); err == nil {
|
||||
t.Fatal("LoadDetail(skipped) error = nil, want skipped service detail cache removed")
|
||||
}
|
||||
if got := skippedRuntimeCalls.Load(); got != 0 {
|
||||
t.Fatalf("skipped runtime calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
|
||||
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
|
||||
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
|
||||
// Kept deliberately generic (no edition-specific strings) — concrete values
|
||||
// belong to the overlay repo that installs the hooks, not to this open core.
|
||||
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var (
|
||||
editionHits atomic.Int32
|
||||
marketHits atomic.Int32
|
||||
gotHeaders atomic.Value // map[string]string
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/edition/apis":
|
||||
editionHits.Add(1)
|
||||
snapshot := map[string]string{
|
||||
"x-test-edition": r.Header.Get("x-test-edition"),
|
||||
"x-test-client": r.Header.Get("x-test-client"),
|
||||
}
|
||||
gotHeaders.Store(snapshot)
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 1},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Edition Service",
|
||||
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "testing",
|
||||
DiscoveryURL: srv.URL + "/cli/edition/apis",
|
||||
DiscoveryHeaders: func() map[string]string {
|
||||
return map[string]string{
|
||||
"x-test-edition": "custom",
|
||||
"x-test-client": "cli-refresh",
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmd := newCacheCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"refresh"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if got := editionHits.Load(); got != 1 {
|
||||
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
|
||||
}
|
||||
if got := marketHits.Load(); got != 0 {
|
||||
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
|
||||
}
|
||||
|
||||
headers, _ := gotHeaders.Load().(map[string]string)
|
||||
if headers == nil {
|
||||
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
|
||||
}
|
||||
if headers["x-test-edition"] != "custom" {
|
||||
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
|
||||
}
|
||||
if headers["x-test-client"] != "cli-refresh" {
|
||||
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(market.DetailResponse{
|
||||
Success: true,
|
||||
Result: market.DetailResult{
|
||||
Tools: []market.DetailTool{
|
||||
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(cli detail payload) error = %v", err)
|
||||
}
|
||||
if err := store.SaveDetail(partition, cliID, cache.DetailSnapshot{
|
||||
MCPID: 0,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDetail(%s) error = %v", cliID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveCachedRuntimeAndDetail(t *testing.T, store *cache.Store, partition, serverKey string) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.SaveTools(partition, serverKey, cache.ToolsSnapshot{
|
||||
ServerKey: serverKey,
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "stale_tool", Title: "Stale Tool"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools(%s) error = %v", serverKey, err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(market.DetailResponse{
|
||||
Success: true,
|
||||
Result: market.DetailResult{
|
||||
Tools: []market.DetailTool{
|
||||
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(detail payload) error = %v", err)
|
||||
}
|
||||
if err := store.SaveDetail(partition, serverKey, cache.DetailSnapshot{
|
||||
MCPID: 0,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDetail(%s) error = %v", serverKey, err)
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
|
||||
// patLikeError simulates an edition-specific PAT error that implements both
|
||||
// ExitCoder (exit code 4) and RawStderrError (raw JSON to stderr).
|
||||
type patLikeError struct{ raw string }
|
||||
|
||||
func (e *patLikeError) Error() string { return e.raw }
|
||||
func (e *patLikeError) ExitCode() int { return 4 }
|
||||
func (e *patLikeError) RawStderr() string { return e.raw }
|
||||
|
||||
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation(
|
||||
"bad flag",
|
||||
apperrors.WithHint("Pass the required flag and retry."),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("Set(format) error = %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation("bad flag"))
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetArgs([]string{"mcp", "doc", "search_documents", "--json", "{"})
|
||||
|
||||
executed, execErr := root.ExecuteC()
|
||||
if execErr == nil {
|
||||
t.Fatal("ExecuteC() error = nil, want validation error")
|
||||
}
|
||||
if executed == nil {
|
||||
t.Fatal("ExecuteC() returned nil command")
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := printExecutionError(executed, &stdout, &stderr, execErr)
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionCommandUsesConfiguredWriter(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"completion", "bash"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "bash completion for dws") {
|
||||
t.Fatalf("output = %q, want completion script in configured writer", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandShowsHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"cache", "nonexistent-cmd"})
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
t.Fatal("ExecuteC() error = nil, want unknown command error")
|
||||
}
|
||||
if !isUnknownCommandError(err) {
|
||||
t.Fatalf("isUnknownCommandError() = false for error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate what Execute() does: redirect output to stderr and print help
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
executed.SetOut(&out)
|
||||
_ = executed.Help()
|
||||
|
||||
combined := out.String()
|
||||
// Help text should include the parent command's usage
|
||||
if !strings.Contains(combined, "cache") {
|
||||
t.Fatalf("output should contain parent command name 'cache', got:\n%s", combined)
|
||||
}
|
||||
// Help text should list available subcommands
|
||||
if !strings.Contains(combined, "Available Commands") {
|
||||
t.Fatalf("output should contain 'Available Commands', got:\n%s", combined)
|
||||
}
|
||||
if !strings.Contains(combined, "refresh") {
|
||||
t.Fatalf("output should list 'refresh' subcommand, got:\n%s", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"version"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-2 * time.Hour),
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"version"})
|
||||
|
||||
start := time.Now()
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(--help) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("root help output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
|
||||
"article": map[string]any{"description": "文档文章"},
|
||||
}, map[string]any{
|
||||
"search_article": map[string]any{
|
||||
"cliName": "search",
|
||||
"group": "article",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"-h"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(-h) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root short help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
|
||||
"article": map[string]any{"description": "文档文章"},
|
||||
}, map[string]any{
|
||||
"search_article": map[string]any{
|
||||
"cliName": "search",
|
||||
"group": "article",
|
||||
"flags": map[string]any{
|
||||
"keyword": map[string]any{"alias": "keyword"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"devdoc", "article", "search", "-h"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "搜索开放平台文档") || !strings.Contains(out.String(), "dws devdoc article search") {
|
||||
t.Fatalf("nested short help output missing command help:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_writes_raw_JSON_to_stderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{}}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty for RawStderrError", stdout.String())
|
||||
}
|
||||
got := strings.TrimSpace(stderr.String())
|
||||
if got != rawJSON {
|
||||
t.Fatalf("stderr = %q, want raw JSON %q", got, rawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_exit_code_is_4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := &patLikeError{raw: `{"code":"PAT_MEDIUM_RISK_NO_PERMISSION"}`}
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
if exitCode != 4 {
|
||||
t.Fatalf("apperrors.ExitCode(patLikeError) = %d, want 4", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION"}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty — RawStderrError should bypass JSON mode", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
|
||||
// named return + defer recover → exitCode = 5 on panic.
|
||||
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
exitCode = 5
|
||||
}
|
||||
}()
|
||||
if doPanic {
|
||||
panic("test panic")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(true)
|
||||
if code != 5 {
|
||||
t.Fatalf("panic recovery exitCode = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_no_panic_returns_0(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(false)
|
||||
if code != 0 {
|
||||
t.Fatalf("no-panic exitCode = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
@@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
renderRootGlobalFlags(root)
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
@@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
flags := visiblePersistentFlags(root)
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
w := root.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, flag := range flags {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
flags := make([]*pflag.Flag, 0)
|
||||
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Hidden {
|
||||
return
|
||||
}
|
||||
flags = append(flags, flag)
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func formatRootFlag(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
name := "--" + flag.Name
|
||||
if flag.Value != nil && flag.Value.Type() != "bool" {
|
||||
name += " " + flag.Value.Type()
|
||||
}
|
||||
if flag.Shorthand == "" {
|
||||
return " " + name
|
||||
}
|
||||
return "-" + flag.Shorthand + ", " + name
|
||||
}
|
||||
|
||||
func commandShort(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
if strings.Contains(help, "● conference") {
|
||||
t.Fatalf("root help should hide conference compatibility command:\n%s", help)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"● dev",
|
||||
"• upgrade",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message list-direct missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
|
||||
for _, flag := range []string{"file", "type"} {
|
||||
if mediaUpload.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat media upload missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
mustFindCommand(t, root, "contact", "get")
|
||||
mustFindCommand(t, root, "contact", "search")
|
||||
mustFindCommand(t, root, "contact", "user", "list")
|
||||
mustFindCommand(t, root, "conference", "meeting", "reserve")
|
||||
}
|
||||
|
||||
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
label := mustFindCommand(t, root, "contact", "label")
|
||||
if label.Hidden {
|
||||
t.Fatal("contact label should be visible as a real command group")
|
||||
}
|
||||
if !containsString(label.Aliases, "role") {
|
||||
t.Fatal("contact label missing role alias")
|
||||
}
|
||||
mustFindCommand(t, root, "contact", "label", "get")
|
||||
mustFindCommand(t, root, "contact", "label", "list")
|
||||
mustFindCommand(t, root, "contact", "label", "list-members")
|
||||
mustFindCommand(t, root, "contact", "label", "find")
|
||||
mustFindCommand(t, root, "contact", "label", "search")
|
||||
mustFindCommand(t, root, "contact", "label", "info")
|
||||
mustFindCommand(t, root, "contact", "label", "detail")
|
||||
mustFindCommand(t, root, "contact", "label", "list-all")
|
||||
|
||||
getSelf := mustFindCommand(t, root, "contact", "user", "get-self")
|
||||
for _, alias := range []string{"self", "me", "whoami", "current"} {
|
||||
if !containsString(getSelf.Aliases, alias) {
|
||||
t.Fatalf("contact user get-self missing alias %q", alias)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "label list",
|
||||
args: []string{"--dry-run", "contact", "label", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "label get",
|
||||
args: []string{"--dry-run", "contact", "label", "get", "--names", "admin,finance"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin", "finance"},
|
||||
},
|
||||
{
|
||||
name: "label members",
|
||||
args: []string{"--dry-run", "contact", "label", "list-members", "--id", "123"},
|
||||
want: []string{"get_label_members_by_labelId", "labelId", "123"},
|
||||
},
|
||||
{
|
||||
name: "role shim",
|
||||
args: []string{"--dry-run", "contact", "role", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "label fuzzy shim",
|
||||
args: []string{"--dry-run", "contact", "label", "find", "--names", "admin"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin"},
|
||||
},
|
||||
{
|
||||
name: "label detail shim",
|
||||
args: []string{"--dry-run", "contact", "label", "detail", "--id", "123"},
|
||||
want: []string{"get_label_members_by_labelId", "labelId", "123"},
|
||||
},
|
||||
{
|
||||
name: "contact search shim",
|
||||
args: []string{"--dry-run", "contact", "search", "--query", "admin"},
|
||||
want: []string{"search_contact_by_key_word", "keyword", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact find shim",
|
||||
args: []string{"--dry-run", "contact", "find", "--query", "admin"},
|
||||
want: []string{"search_contact_by_key_word", "keyword", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact list defaults to label list",
|
||||
args: []string{"--dry-run", "contact", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "contact list department members",
|
||||
args: []string{"--dry-run", "contact", "list", "--depts", "1"},
|
||||
want: []string{"get_dept_members_by_deptId", "deptIds", "1"},
|
||||
},
|
||||
{
|
||||
name: "contact get user details",
|
||||
args: []string{"--dry-run", "contact", "get", "--ids", "user1"},
|
||||
want: []string{"get_user_info_by_user_ids", "user_id_list", "user1"},
|
||||
},
|
||||
{
|
||||
name: "contact get label by name",
|
||||
args: []string{"--dry-run", "contact", "get", "--names", "admin"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact self shim",
|
||||
args: []string{"--dry-run", "contact", "self"},
|
||||
want: []string{"get_current_user_profile"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, tc.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%v) error = %v\n%s", tc.args, err, got)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("Execute(%v) output missing %q:\n%s", tc.args, want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
fileCmd := mustFindCommand(t, root, "chat", "file")
|
||||
if !fileCmd.Hidden {
|
||||
t.Fatal("chat file should be hidden after upload_conversation_file_by_url downline")
|
||||
}
|
||||
upload := mustFindCommand(t, root, "chat", "file", "upload")
|
||||
if !upload.Hidden {
|
||||
t.Fatal("chat file upload should be hidden after downline")
|
||||
}
|
||||
for _, flag := range []string{"group", "url", "file", "file-name"} {
|
||||
if upload.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat file upload missing compatibility flag --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
send := mustFindCommand(t, root, "chat", "message", "send")
|
||||
for _, flag := range []string{"msg-type", "file-path"} {
|
||||
if send.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message send missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"chat", "file", "upload",
|
||||
"--group", "cid",
|
||||
"--url", "https://example.com/report.pdf",
|
||||
"--file-name", "report.pdf",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--start", "2026-07-07T00:00:00+08:00",
|
||||
"--end", "2026-07-07T01:00:00+08:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event list --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"list_calendar_events", "startTime", "endTime"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
listBySender := mustFindCommand(t, root, "chat", "message", "list-by-sender")
|
||||
if listBySender.Flags().Lookup("sender") == nil {
|
||||
t.Fatal("chat message list-by-sender missing hidden --sender alias")
|
||||
}
|
||||
|
||||
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
|
||||
for _, flag := range []string{"sender", "senders", "sender-ids"} {
|
||||
if searchAdvanced.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message search-advanced missing --%s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "static-test",
|
||||
Name: "Static Test",
|
||||
Endpoint: "https://static.example/server/static-test",
|
||||
Prefixes: []string{"static-alias"},
|
||||
}}
|
||||
},
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "supplement-test",
|
||||
Name: "Supplement Test",
|
||||
Endpoint: "https://supplement.example/server/supplement-test",
|
||||
Prefixes: []string{"supplement-alias"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, tc := range []struct {
|
||||
productID string
|
||||
endpoint string
|
||||
}{
|
||||
{"static-test", "https://static.example/server/static-test"},
|
||||
{"static-alias", "https://static.example/server/static-test"},
|
||||
{"supplement-test", "https://supplement.example/server/supplement-test"},
|
||||
{"supplement-alias", "https://supplement.example/server/supplement-test"},
|
||||
} {
|
||||
got, ok := directRuntimeEndpoint(tc.productID, "")
|
||||
if !ok || got != tc.endpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", tc.productID, got, ok, tc.endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
for _, name := range path {
|
||||
var next *cobra.Command
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == name {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("missing command path %q under %q", strings.Join(path, " "), cmd.CommandPath())
|
||||
}
|
||||
cmd = next
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func executeRootCaptureStdout(t *testing.T, args []string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
readPipe, writePipe, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe error = %v", err)
|
||||
}
|
||||
os.Stdout = writePipe
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(args)
|
||||
execErr := cmd.Execute()
|
||||
|
||||
_ = writePipe.Close()
|
||||
os.Stdout = oldStdout
|
||||
captured, readErr := io.ReadAll(readPipe)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read stdout pipe error = %v", readErr)
|
||||
}
|
||||
return out.String() + string(captured), execErr
|
||||
}
|
||||
+222
-31
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -161,6 +162,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if multi {
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -178,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
if prefetchToken {
|
||||
go getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
@@ -238,6 +253,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
Selector string
|
||||
Profile authpkg.Profile
|
||||
}
|
||||
|
||||
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
|
||||
rawSelector = strings.TrimSpace(rawSelector)
|
||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(rawSelector, ",")
|
||||
selections := make([]multiProfileSelection, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
selector := strings.TrimSpace(part)
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||
}
|
||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, false, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
if seen[profile.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[profile.CorpID] = true
|
||||
selections = append(selections, multiProfileSelection{
|
||||
Selector: selector,
|
||||
Profile: *profile,
|
||||
})
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return selections, true, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
defer authpkg.SetRuntimeProfile(previousProfile)
|
||||
|
||||
entries := make([]any, 0, len(selections))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
entry := map[string]any{
|
||||
"selector": selection.Selector,
|
||||
"corpId": selection.Profile.CorpID,
|
||||
"corpName": selection.Profile.CorpName,
|
||||
"ok": err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
entry["error"] = multiProfileErrorPayload(err)
|
||||
} else {
|
||||
succeeded++
|
||||
if payload := multiProfileResultPayload(result); payload != nil {
|
||||
entry["result"] = payload
|
||||
}
|
||||
if result.Response != nil {
|
||||
if endpoint, ok := result.Response["endpoint"]; ok {
|
||||
entry["endpoint"] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": failed == 0,
|
||||
"multiProfile": true,
|
||||
"summary": map[string]any{
|
||||
"total": len(selections),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
"profiles": entries,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
|
||||
cloned := invocation
|
||||
if invocation.Params != nil {
|
||||
cloned.Params = make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
cloned.Params[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func multiProfileResultPayload(result executor.Result) any {
|
||||
if result.Response == nil {
|
||||
return nil
|
||||
}
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
return result.Response
|
||||
}
|
||||
|
||||
func multiProfileErrorPayload(err error) map[string]any {
|
||||
payload := map[string]any{
|
||||
"message": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
payload["category"] = string(typed.Category)
|
||||
if typed.Reason != "" {
|
||||
payload["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
@@ -259,12 +412,16 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
|
||||
actions := []string{"dws cache refresh"}
|
||||
hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。"
|
||||
actions := []string{
|
||||
"确认 internal/syncdata.StaticServers() 是否包含该 product/server",
|
||||
"运行 sync-oss 重新生成静态端点与路由",
|
||||
"若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令",
|
||||
}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
|
||||
"检查 StaticServers/SupplementServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
@@ -333,6 +490,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
|
||||
// surface advertises the resolved MCP arguments without polluting the
|
||||
// stdout payload (which stays valid JSON in --format json mode). Mirrors
|
||||
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
|
||||
// machine-readable channel.
|
||||
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
@@ -492,6 +657,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
// Align with wukong's response envelope: stamp a top-level success=true on
|
||||
// map payloads that don't already carry a success flag. Business errors
|
||||
// (success=false) are intercepted above, so reaching here means the call
|
||||
// succeeded. Additive only — existing keys are never overwritten.
|
||||
if callResult.Content != nil {
|
||||
if _, has := callResult.Content["success"]; !has {
|
||||
callResult.Content["success"] = true
|
||||
}
|
||||
}
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": callResult.Content,
|
||||
@@ -586,28 +760,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeToken string
|
||||
cachedRuntimeTokenOnce sync.Once
|
||||
cachedRuntimeTokenMu sync.Mutex
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
)
|
||||
|
||||
// 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() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
if cacheKey == "" {
|
||||
cacheKey = "__default__"
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
}
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
cachedRuntimeTokens[cacheKey] = token
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
@@ -622,8 +808,9 @@ func generateExecutionID() string {
|
||||
// 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 = ""
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
defer cachedRuntimeTokenMu.Unlock()
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
@@ -696,20 +883,21 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
|
||||
// per-(machine × agent_code) instance id. This is what makes agent_code
|
||||
// actually report a value: previously it was sent only when the host
|
||||
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
|
||||
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
|
||||
// per-(machine × agent_code) instance id when a code is known. Synthetic
|
||||
// fallbacks must not be sent because PAT authorization checks use the same
|
||||
// header as their grant key.
|
||||
//
|
||||
// Backward-compat by design (additive, not breaking):
|
||||
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
|
||||
// (set by id.Headers() above), so old/new clients stay comparable.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
|
||||
// Old clients don't send it, which is itself a clean old/new signal.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
|
||||
// sent only when x-dingtalk-dws-agent-code is non-empty.
|
||||
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
|
||||
agentCode, agentCodeSig := authpkg.DetectAgentCode()
|
||||
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
|
||||
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
|
||||
headers["x-dws-agent-instance-id"] = agentInstanceID
|
||||
}
|
||||
|
||||
// Emit the CLI version on the wire so the gateway can segment old vs new
|
||||
// clients (and scope agent_code coverage / adoption). The header constant
|
||||
@@ -739,6 +927,9 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
|
||||
// catalog has the product entry (materialised from SupplementServers) but
|
||||
// no tool list — the overlay owns the tool tree locally.
|
||||
type supplementOnlyCatalogLoader struct{}
|
||||
|
||||
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
return ir.Catalog{
|
||||
Products: []ir.CanonicalProduct{
|
||||
{
|
||||
ID: "conference",
|
||||
ServerKey: "conference",
|
||||
Endpoint: "stdio://conference-catalog",
|
||||
Tools: nil,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetDynamicServers(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := snapshotDynamicServers()
|
||||
t.Cleanup(func() { restoreDynamicServers(orig) })
|
||||
}
|
||||
|
||||
type dynamicServerSnapshot struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}
|
||||
|
||||
func snapshotDynamicServers() dynamicServerSnapshot {
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
return dynamicServerSnapshot{
|
||||
endpoints: cloneStringMap(dynamicEndpoints),
|
||||
products: cloneBoolMap(dynamicProducts),
|
||||
aliases: cloneStringMap(dynamicAliases),
|
||||
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDynamicServers(s dynamicServerSnapshot) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
dynamicEndpoints = s.endpoints
|
||||
dynamicProducts = s.products
|
||||
dynamicAliases = s.aliases
|
||||
dynamicToolEndpoints = s.toolEndpoints
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBoolMap(in map[string]bool) map[string]bool {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
|
||||
// bridge between the catalog path (where a product entry can come from
|
||||
// SupplementServers with no tool list) and the direct-runtime path (which
|
||||
// carries the authoritative per-tool endpoint map). When the catalog knows
|
||||
// the product but not the tool, the runner should not fail-fast with
|
||||
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
|
||||
// and proceed if an endpoint is registered.
|
||||
//
|
||||
// This is the narrow recovery path that keeps hardcoded overlay commands
|
||||
// working under a gray-released envelope: the supplement-materialised
|
||||
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
|
||||
// operational endpoint indexed by product / command.
|
||||
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
DisplayName: "会议",
|
||||
Endpoint: "stdio://conference-fake",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "conference",
|
||||
Command: "conference",
|
||||
},
|
||||
Source: "edition_supplement",
|
||||
},
|
||||
})
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
// Kind = api_invocation forces the code to skip the Run() opening
|
||||
// direct-runtime attempt and go through the catalog path instead, so
|
||||
// the tool-miss recovery branch we're testing actually runs.
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "create_meeting_reservation",
|
||||
CanonicalPath: "conference.create_meeting_reservation",
|
||||
DryRun: true,
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), inv)
|
||||
if err != nil {
|
||||
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
|
||||
}
|
||||
if result.Response == nil {
|
||||
t.Fatalf("expected non-nil Response on dry-run")
|
||||
}
|
||||
if got, _ := result.Response["dry_run"].(bool); !got {
|
||||
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
|
||||
}
|
||||
if got, _ := result.Response["transport"].(string); got != "stdio" {
|
||||
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
|
||||
// guard: when both the catalog tool list and dynamicEndpoints have no
|
||||
// record for the requested tool, the runner must still surface
|
||||
// endpoint_not_resolved instead of silently producing empty output.
|
||||
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "nonexistent_tool",
|
||||
CanonicalPath: "conference.nonexistent_tool",
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
_, err := runner.Run(context.Background(), inv)
|
||||
if err == nil {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "endpoint not resolved") {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nonexistent_tool") {
|
||||
t.Fatalf("error should name the missing tool; got %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -210,7 +210,9 @@ func newSkillSearchCommand() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("query")
|
||||
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
|
||||
cmd.Flags().String("source", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)")
|
||||
cmd.Flags().String("scopes", "", "查询范围(已废弃,请使用 --source)")
|
||||
_ = cmd.Flags().MarkDeprecated("scopes", "请使用 --source 替代")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -289,15 +291,18 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
|
||||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
keyword, _ := cmd.Flags().GetString("query")
|
||||
scopes, _ := cmd.Flags().GetString("scopes")
|
||||
source, _ := cmd.Flags().GetString("source")
|
||||
if source == "" {
|
||||
source, _ = cmd.Flags().GetString("scopes")
|
||||
}
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
|
||||
if scopes != "" {
|
||||
apiURL += "&scopes=" + url.QueryEscape(scopes)
|
||||
if source != "" {
|
||||
apiURL += "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -653,6 +653,88 @@ func TestSkillSearchCommandValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSearchHelpUsesWukongSourceAndKeepsScopesHidden(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "search", "--help"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "--source") {
|
||||
t.Fatalf("help missing --source:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--scopes") {
|
||||
t.Fatalf("deprecated --scopes should stay hidden from help:\n%s", help)
|
||||
}
|
||||
|
||||
search := mustFindCommand(t, NewRootCommand(), "skill", "search")
|
||||
if search.Flags().Lookup("scopes") == nil {
|
||||
t.Fatal("skill search missing hidden compatibility --scopes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
|
||||
configDir := filepath.Join(t.TempDir(), "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if 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),
|
||||
}); err != nil {
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
|
||||
var gotSources []string
|
||||
var gotScopes []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cli/find-skills" {
|
||||
t.Fatalf("path = %q, want /cli/find-skills", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("x-user-access-token"); got != "test-token" {
|
||||
t.Fatalf("x-user-access-token = %q, want test-token", got)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
gotSources = append(gotSources, q.Get("source"))
|
||||
gotScopes = append(gotScopes, q.Get("scopes"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"result":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("DWS_SKILL_API_HOST", server.URL)
|
||||
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs(args)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) error = %v\n%s", args, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
run("skill", "search", "--query", "周报", "--source", "OrgInternal", "--format", "json")
|
||||
run("skill", "search", "--query", "周报", "--scopes", "DingtalkMarket", "--format", "json")
|
||||
|
||||
if len(gotSources) != 2 {
|
||||
t.Fatalf("request count = %d, want 2", len(gotSources))
|
||||
}
|
||||
if gotSources[0] != "OrgInternal" || gotSources[1] != "DingtalkMarket" {
|
||||
t.Fatalf("source query values = %#v, want OrgInternal/DingtalkMarket", gotSources)
|
||||
}
|
||||
if gotScopes[0] != "" || gotScopes[1] != "" {
|
||||
t.Fatalf("deprecated scopes must be normalized to source query, got scopes=%#v", gotScopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillFindHintCommand(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "find"})
|
||||
|
||||
@@ -123,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
multiSkillNames = filtered
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
@@ -160,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
|
||||
const multiSkillPrefix = "dingtalk-"
|
||||
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||||
hasShared := false
|
||||
for _, n := range all {
|
||||
if n == multiSharedSkill {
|
||||
hasShared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasShared {
|
||||
return selected
|
||||
}
|
||||
for _, n := range selected {
|
||||
if n == multiSharedSkill {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
return append([]string{multiSharedSkill}, selected...)
|
||||
}
|
||||
|
||||
// normalizeMultiSkillName accepts either the short form (aitable) or the
|
||||
// full form (dingtalk-aitable) and returns the canonical full form.
|
||||
// Empty input returns "". Comparison is case-insensitive.
|
||||
|
||||
@@ -66,3 +66,16 @@ func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
|
||||
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryDoesNotTrackInstalledQoderSkills(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
if _, err := os.Stat(filepath.Join(repoRoot, ".qoder", "skills")); err == nil {
|
||||
t.Fatal(".qoder/skills is an Agent install target, not a repository skill source; keep source skills under skills/")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat .qoder/skills: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+63
-31
@@ -42,6 +42,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
flagForce bool
|
||||
flagSkipSkills bool
|
||||
flagAll bool
|
||||
flagBeta bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -54,8 +55,10 @@ func newUpgradeCommand() *cobra.Command {
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --list --all # 列出所选轨道的全部版本
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定正式版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
@@ -72,19 +75,23 @@ func newUpgradeCommand() *cobra.Command {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
track := upgradeTrack(flagBeta)
|
||||
if flagBeta && flagVersion != "" {
|
||||
return fmt.Errorf("--beta 与 --version 不能同时使用;安装指定 beta 版本请直接使用 --version vX.Y.Z-beta.N")
|
||||
}
|
||||
|
||||
if flagList {
|
||||
limit := defaultListLimit
|
||||
if flagAll {
|
||||
limit = 0
|
||||
}
|
||||
return runUpgradeList(cmd, format, limit)
|
||||
return runUpgradeList(cmd, format, limit, track)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
}
|
||||
if flagCheck {
|
||||
return runUpgradeCheck(cmd, format)
|
||||
return runUpgradeCheck(cmd, format, track)
|
||||
}
|
||||
return runUpgrade(cmd.Context(), upgradeOptions{
|
||||
targetVersion: flagVersion,
|
||||
@@ -92,14 +99,16 @@ func newUpgradeCommand() *cobra.Command {
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
track: track,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本")
|
||||
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出正式 release 版本(配合 --beta 查看 beta)")
|
||||
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所选轨道的全部版本")
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
|
||||
@@ -113,18 +122,19 @@ type upgradeOptions struct {
|
||||
skipSkills bool
|
||||
yes bool
|
||||
dryRun bool
|
||||
track upgrade.ReleaseTrack
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
|
||||
func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
|
||||
}
|
||||
|
||||
latest, err := client.FetchLatestRelease()
|
||||
latest, err := client.FetchLatestReleaseForTrack(track)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
@@ -137,6 +147,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
"current_version": ensureV(currentVer),
|
||||
"latest_version": "v" + latest.Version,
|
||||
"needs_upgrade": needsUpgrade,
|
||||
"track": string(track),
|
||||
"release_date": latest.Date,
|
||||
"prerelease": latest.Prerelease,
|
||||
"changelog": parseChangelogEntries(latest.Changelog, 10),
|
||||
@@ -155,7 +166,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
|
||||
}
|
||||
if latest.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
fmt.Printf(" %s %s\n", ugBold("轨道: "), ugYellow("beta / pre-release"))
|
||||
}
|
||||
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
|
||||
fmt.Printf(" %s\n", ugBold("更新内容:"))
|
||||
@@ -164,7 +175,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
|
||||
fmt.Printf(" %s\n", ugDim(upgradeHintForTrack(track)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,14 +183,14 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
|
||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
|
||||
}
|
||||
|
||||
versions, err := client.FetchAllReleases()
|
||||
versions, err := client.FetchReleaseVersions(track)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
@@ -194,7 +205,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
currentVer := strings.TrimPrefix(version, "v")
|
||||
|
||||
if format == "json" {
|
||||
var items []map[string]any
|
||||
items := make([]map[string]any, 0, len(versions))
|
||||
for _, v := range versions {
|
||||
items = append(items, map[string]any{
|
||||
"version": "v" + v.Version,
|
||||
@@ -207,6 +218,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
result := map[string]any{
|
||||
"current_version": ensureV(version),
|
||||
"versions": items,
|
||||
"track": string(track),
|
||||
"total": totalCount,
|
||||
}
|
||||
if truncated {
|
||||
@@ -217,7 +229,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
}
|
||||
|
||||
if totalCount == 0 {
|
||||
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
|
||||
fmt.Printf(" %s\n", ugYellow(fmt.Sprintf("未找到任何%s", upgradeTrackVersionName(track))))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,7 +240,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
for _, v := range versions {
|
||||
releaseType := ugGreen("stable")
|
||||
if v.Prerelease {
|
||||
releaseType = ugYellow("pre-release")
|
||||
releaseType = ugYellow("beta")
|
||||
}
|
||||
versionStr := fmt.Sprintf("v%-11s", v.Version)
|
||||
marker := ""
|
||||
@@ -245,7 +257,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
if truncated {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount)))
|
||||
}
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本"))
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --beta 安装最新 beta;使用 --version v1.0.7 安装指定版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -327,7 +339,7 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills
|
||||
}
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
|
||||
|
||||
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
|
||||
return fmt.Errorf("初始化目录结构失败: %w", err)
|
||||
@@ -340,13 +352,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
var err error
|
||||
|
||||
if opts.targetVersion != "" {
|
||||
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
|
||||
fmt.Printf(" 指定版本: %s\n", ugCyan(ensureV(opts.targetVersion)))
|
||||
release, err = client.FetchReleaseByTag(opts.targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
|
||||
}
|
||||
} else {
|
||||
release, err = client.FetchLatestRelease()
|
||||
release, err = client.FetchLatestReleaseForTrack(opts.track)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
@@ -364,7 +376,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
|
||||
}
|
||||
if release.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
fmt.Printf(" %s %s\n", ugBold("轨道: "), ugYellow("beta / pre-release"))
|
||||
}
|
||||
|
||||
// --dry-run: preview only. Resolve the platform asset so a missing build is
|
||||
@@ -560,15 +572,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Clear discovery-derived caches so the upgraded binary rebuilds its
|
||||
// command tree from a fresh fetch instead of inheriting snapshots written
|
||||
// by the old version — a poisoned snapshot used to lock out every
|
||||
// invocation before the build guards landed (#447 / #449).
|
||||
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
|
||||
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
|
||||
} else if len(purged) > 0 {
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
|
||||
}
|
||||
// Discovery cache purge removed — static endpoint mode has no discovery cache.
|
||||
|
||||
// Cleanup old backups
|
||||
rm.Cleanup(5)
|
||||
@@ -816,6 +820,34 @@ func ensureV(ver string) string {
|
||||
return ver
|
||||
}
|
||||
|
||||
func upgradeTrack(beta bool) upgrade.ReleaseTrack {
|
||||
if beta {
|
||||
return upgrade.ReleaseTrackBeta
|
||||
}
|
||||
return upgrade.ReleaseTrackRelease
|
||||
}
|
||||
|
||||
func upgradeTrackSuffix(track upgrade.ReleaseTrack) string {
|
||||
if track == upgrade.ReleaseTrackBeta {
|
||||
return " (beta)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func upgradeTrackVersionName(track upgrade.ReleaseTrack) string {
|
||||
if track == upgrade.ReleaseTrackBeta {
|
||||
return "beta 版本"
|
||||
}
|
||||
return "正式 release 版本"
|
||||
}
|
||||
|
||||
func upgradeHintForTrack(track upgrade.ReleaseTrack) string {
|
||||
if track == upgrade.ReleaseTrackBeta {
|
||||
return "运行 dws upgrade --beta 进行升级"
|
||||
}
|
||||
return "运行 dws upgrade 进行升级"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -395,7 +395,7 @@ func TestNewUpgradeCommand_Flags(t *testing.T) {
|
||||
t.Errorf("Use = %q, want upgrade", cmd.Use)
|
||||
}
|
||||
|
||||
expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
|
||||
expectedFlags := []string{"check", "list", "version", "beta", "rollback", "force", "skip-skills"}
|
||||
for _, name := range expectedFlags {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("missing flag: --%s", name)
|
||||
@@ -430,6 +430,9 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
if !strings.Contains(help, "--beta") {
|
||||
t.Error("help should contain --beta")
|
||||
}
|
||||
// Regression for #364: --dry-run must be discoverable from upgrade help so
|
||||
// users know it is supported (and is now actually honored).
|
||||
if !strings.Contains(help, "--dry-run") {
|
||||
@@ -437,6 +440,30 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUpgradeCommand_BetaAndVersionAreMutuallyExclusive(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
cmd.SetArgs([]string{"--beta", "--version", "v1.0.8-beta.1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --beta with --version")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--beta") || !strings.Contains(err.Error(), "--version") {
|
||||
t.Fatalf("error = %q, want to mention --beta and --version", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTrack(t *testing.T) {
|
||||
if got := upgradeTrack(false); got != "release" {
|
||||
t.Fatalf("upgradeTrack(false) = %q, want release", got)
|
||||
}
|
||||
if got := upgradeTrack(true); got != "beta" {
|
||||
t.Fatalf("upgradeTrack(true) = %q, want beta", got)
|
||||
}
|
||||
if got := upgradeHintForTrack("beta"); !strings.Contains(got, "--beta") {
|
||||
t.Fatalf("upgradeHintForTrack(beta) = %q, want --beta hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeDryRunPlan (#364) ---
|
||||
//
|
||||
// Regression for #364: `dws upgrade --dry-run` previously performed a real
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// overrideVisibleProducts temporarily installs an edition hook exposing the
|
||||
// given static product list and restores the previous hooks on cleanup.
|
||||
func overrideVisibleProducts(t *testing.T, products []string) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
VisibleProducts: func() []string { return products },
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
|
||||
// the product ID ends up in DirectRuntimeProductIDs() without triggering
|
||||
// network discovery.
|
||||
func registerPluginProduct(t *testing.T, id, endpoint string) {
|
||||
t.Helper()
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
|
||||
// is a regression for the dws-wukong plugin-visibility bug: when an edition
|
||||
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
|
||||
// IDs) and a plugin registers a new product via AppendDynamicServer
|
||||
// (e.g. `conference-local`), the plugin command must stay visible because the
|
||||
// dynamic registry takes precedence over the hook's static whitelist.
|
||||
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if calendarCmd.Hidden {
|
||||
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
|
||||
}
|
||||
if !bogusCmd.Hidden {
|
||||
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
|
||||
// renderer surfaces plugin products in the "Discovered MCP Services" section
|
||||
// and does not misclassify them as utility commands.
|
||||
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
authCmd := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, authCmd)
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
|
||||
}
|
||||
if !containsCommand(services, "calendar") {
|
||||
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
|
||||
}
|
||||
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
if containsCommand(utilities, "conference-local") {
|
||||
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
|
||||
}
|
||||
if !containsCommand(utilities, "auth") {
|
||||
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommand(cmds []*cobra.Command, name string) bool {
|
||||
for _, c := range cmds {
|
||||
if c.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandNames(cmds []*cobra.Command) []string {
|
||||
names := make([]string, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestRenderRootHelpIncludesLong guards that renderRootHelp surfaces the
|
||||
// root command's Long description in `dws --help` output. The custom
|
||||
// SetHelpFunc in root_help.go replaces cobra's default help template, which
|
||||
// previously caused root.Long to be silently dropped. The production
|
||||
// root.Long carries the "use 'dws upgrade' if a command is missing or
|
||||
// failing" hint that AI agents rely on when they cannot find a suitable
|
||||
// command — if this test fails after a help-rendering change, agents will
|
||||
// silently lose that guidance.
|
||||
func TestRenderRootHelpIncludesLong(t *testing.T) {
|
||||
const sentinel = "SENTINEL-LONG-MUST-APPEAR-IN-HELP"
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Long: sentinel,
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
|
||||
renderRootHelp(root)
|
||||
|
||||
if !strings.Contains(out.String(), sentinel) {
|
||||
t.Fatalf("renderRootHelp must render root.Long verbatim in --help output; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
// family (VSCODE_BRAND covers every VS Code fork, present and future).
|
||||
// - Every per-host signature below is OBSERVED on a real host (live process
|
||||
// env via `ps eww`, or the app bundle Info.plist), not guessed.
|
||||
// - Anything unidentified falls back to AgentCodeCustom — never guess.
|
||||
// - Anything unidentified stays empty — never guess or synthesize a PAT key.
|
||||
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
|
||||
// not the agent host) and fuzzy parent-process name matching.
|
||||
package auth
|
||||
@@ -33,7 +33,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentCodeCustom is the honest fallback for any host we cannot identify.
|
||||
// AgentCodeCustom is the literal code a host may explicitly declare for a
|
||||
// custom integration. It is not used as an implicit fallback.
|
||||
const AgentCodeCustom = "custom"
|
||||
|
||||
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
|
||||
@@ -66,7 +67,7 @@ var knownSignatures = []hostSignature{
|
||||
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
|
||||
// self-identifying env marker — only user-set API-key/config vars, which we
|
||||
// must not key off (a user setting GEMINI_API_KEY is not "running under
|
||||
// gemini"). They therefore resolve to custom unless they declare themselves.
|
||||
// gemini"). They therefore resolve to empty unless they declare themselves.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
|
||||
@@ -78,7 +79,7 @@ var knownSignatures = []hostSignature{
|
||||
// id is exposed via __CFBundleIdentifier and inherited by child processes the
|
||||
// IDE spawns (including dws), so it identifies the host even from an integrated
|
||||
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
|
||||
// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
|
||||
// bundles map; everything else (iTerm, Terminal, ...) falls through to empty.
|
||||
//
|
||||
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
|
||||
// this map is simply a no-op there (os.Getenv returns "").
|
||||
@@ -96,11 +97,11 @@ var bundleIDToCode = map[string]string{
|
||||
// T1 verified per-agent env signature (CLI/daemon agents)
|
||||
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
|
||||
// T3 macOS app bundle id (known agent bundles only)
|
||||
// T4 fallback -> custom (never guess)
|
||||
// T4 unresolved -> empty (never guess)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return normalizeAgentCode(v), "env:" + name
|
||||
return v, "env:" + name
|
||||
}
|
||||
|
||||
// T1: verified per-agent env signature (most specific — wins over the IDE
|
||||
@@ -127,8 +128,8 @@ func DetectAgentCode() (code string, signal string) {
|
||||
}
|
||||
}
|
||||
|
||||
// T4: unknown host — honest fallback, no guessing.
|
||||
return AgentCodeCustom, "fallback"
|
||||
// T4: unknown host — leave agent_code empty, no guessing.
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
|
||||
@@ -140,11 +141,13 @@ func normalizeAgentCode(raw string) string {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
switch s {
|
||||
case "":
|
||||
return AgentCodeCustom
|
||||
return ""
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder", "qoderwork":
|
||||
case "qoder":
|
||||
return "qoder"
|
||||
case "qoderwork":
|
||||
return "QoderWork"
|
||||
case "workbuddy", "work-buddy":
|
||||
return "workbuddy"
|
||||
case "visualstudiocode", "code", "code-oss", "vscode":
|
||||
|
||||
@@ -38,10 +38,10 @@ func clearAgentCodeEnv(t *testing.T) {
|
||||
|
||||
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(AgentCodeEnv, "Qoder")
|
||||
t.Setenv(AgentCodeEnv, "QoderWork")
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "qoder" {
|
||||
t.Fatalf("want qoder, got %q", code)
|
||||
if code != "QoderWork" {
|
||||
t.Fatalf("want verbatim QoderWork, got %q", code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
|
||||
t.Fatalf("want env signal, got %q", sig)
|
||||
@@ -119,25 +119,24 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
|
||||
// custom.
|
||||
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled.
|
||||
func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("unknown bundle must be custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("unknown bundle must be empty, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
|
||||
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("want custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("want empty code, got %q", code)
|
||||
}
|
||||
if sig != "fallback" {
|
||||
t.Fatalf("want fallback, got %q", sig)
|
||||
if sig != "" {
|
||||
t.Fatalf("want empty signal, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +146,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
|
||||
t.Setenv("TERM_PROGRAM", "iTerm.app")
|
||||
t.Setenv("DWS_CHANNEL", "Qoderwork")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("noise must not decide agent_code; want empty, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +171,11 @@ func TestNormalizeAgentCode(t *testing.T) {
|
||||
"claude": "claudecode",
|
||||
"Claude-Code": "claudecode",
|
||||
"CLAUDECODE": "claudecode",
|
||||
"Qoderwork": "qoder",
|
||||
"Qoderwork": "QoderWork",
|
||||
"WorkBuddy": "workbuddy",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Cursor": "cursor",
|
||||
"": AgentCodeCustom,
|
||||
"": "",
|
||||
"some-new-ide": "some-new-ide",
|
||||
}
|
||||
for in, want := range cases {
|
||||
|
||||
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
data, err := provider.parseMCPTokenResponse([]byte(`{
|
||||
"accessToken": "access-123",
|
||||
"refreshToken": "refresh-456",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "ding123",
|
||||
"corpName": "钉钉(中国)信息技术有限公司"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpID != "ding123" {
|
||||
t.Fatalf("corp id = %q, want ding123", data.CorpID)
|
||||
}
|
||||
if data.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "snake",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
|
||||
want: "Snake Corp",
|
||||
},
|
||||
{
|
||||
name: "orgName",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
|
||||
want: "Org Corp",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpName != tc.want {
|
||||
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
|
||||
if !strings.Contains(authURL, "corpId=ding-target") {
|
||||
t.Fatalf("auth URL missing target corpId: %s", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -29,6 +29,13 @@ func TestClassifyDenialReason(t *testing.T) {
|
||||
},
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "error ENTERPRISE_NOT_AUTHORIZED",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "ENTERPRISE_NOT_AUTHORIZED",
|
||||
},
|
||||
want: "enterprise_not_authorized",
|
||||
},
|
||||
{
|
||||
name: "error NO_AUTH",
|
||||
status: &CLIAuthStatus{
|
||||
|
||||
@@ -276,6 +276,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
||||
case "enterprise_not_authorized":
|
||||
msg := i18n.T("本次请求未通过企业安全认证")
|
||||
if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" {
|
||||
msg = strings.TrimSpace(authStatus.ErrorMsg)
|
||||
}
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(msg)
|
||||
case "no_auth":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// applyEditionEnterpriseCredentialHeaders injects overlay-provided enterprise
|
||||
// credential headers (e.g. x-dws-enterprise-credential) into MCP control-plane
|
||||
// and OAuth proxy requests.
|
||||
func applyEditionEnterpriseCredentialHeaders(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
fn := edition.Get().EnterpriseCredentialHeaders
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
merged := fn(nil)
|
||||
for k, v := range merged {
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
if k != "" && v != "" {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string {
|
||||
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
|
||||
// persisting it on first sight of an agentCode. Idempotent: the same machine
|
||||
// and agentCode always yields the same id, which is what makes cumulative
|
||||
// per-agent_code statistics possible. An empty agentCode is treated as the
|
||||
// custom bucket.
|
||||
// per-agent_code statistics possible. An empty agentCode has no per-agent
|
||||
// identity and returns empty.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
agentCode = AgentCodeCustom
|
||||
return ""
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
|
||||
@@ -74,13 +74,12 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
|
||||
func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
got := id.ResolveAgentID(dir, "", "fallback")
|
||||
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
|
||||
if got != want {
|
||||
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
|
||||
if got != "" {
|
||||
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
@@ -30,6 +31,24 @@ var (
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
|
||||
}
|
||||
|
||||
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
|
||||
func TokenAccountForCorpID(corpID string) string {
|
||||
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
|
||||
}
|
||||
|
||||
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
|
||||
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
|
||||
}
|
||||
|
||||
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal token data: %w", err)
|
||||
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
|
||||
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
||||
return fmt.Errorf("save to keychain: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
|
||||
// LoadTokenDataKeychain loads TokenData from the platform keychain.
|
||||
func LoadTokenDataKeychain() (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
|
||||
return loadTokenDataKeychainAccount(keychain.AccountToken)
|
||||
}
|
||||
|
||||
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
|
||||
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain")
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
|
||||
func DeleteTokenDataKeychainForCorpID(corpID string) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychain checks if token data exists in keychain.
|
||||
func TokenDataExistsKeychain() bool {
|
||||
return keychain.Exists(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
|
||||
func TokenDataExistsKeychainForCorpID(corpID string) bool {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return false
|
||||
}
|
||||
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// EnsureMigration performs one-time migration from legacy .data to keychain.
|
||||
// This should be called early in the auth flow (e.g., during GetAccessToken).
|
||||
// The migration is idempotent and thread-safe.
|
||||
|
||||
@@ -18,11 +18,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -143,9 +145,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -185,9 +191,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -203,6 +213,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -259,7 +270,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// parseMCPTokenResponse parses token response from MCP proxy.
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
||||
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
var resp struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
@@ -267,6 +278,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
PersistentCode string `json:"persistentCode"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
CorpNameSnake string `json:"corp_name"`
|
||||
OrgName string `json:"orgName"`
|
||||
// Error fields (when request fails)
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
@@ -293,6 +307,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
||||
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
||||
CorpID: resp.CorpID,
|
||||
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
|
||||
}
|
||||
if resp.PersistentCode != "" {
|
||||
data.PersistentCode = resp.PersistentCode
|
||||
@@ -300,7 +315,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI string) string {
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -308,6 +332,9 @@ func buildAuthURL(clientID, redirectURI string) string {
|
||||
"scope": {DefaultScopes},
|
||||
"prompt": {"consent"},
|
||||
}
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
@@ -1118,6 +1145,112 @@ const channelDeniedHTML = `<!doctype html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const enterpriseDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>企业安全认证未通过</h1>
|
||||
<p>__ENTERPRISE_DENIED_MSG__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg.
|
||||
const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证"
|
||||
|
||||
// renderEnterpriseDeniedHTML injects the server-provided denial message (falling
|
||||
// back to the default text) into the enterprise-denied page. The message is
|
||||
// HTML-escaped before insertion.
|
||||
func renderEnterpriseDeniedHTML(serverMsg string) string {
|
||||
msg := strings.TrimSpace(serverMsg)
|
||||
if msg == "" {
|
||||
msg = defaultEnterpriseDeniedMsg
|
||||
}
|
||||
return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。")
|
||||
}
|
||||
|
||||
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
||||
type CLIAuthStatus struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -1154,6 +1287,9 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
|
||||
if status.ErrorCode == "CHANNEL_REQUIRED" {
|
||||
return "channel_required"
|
||||
}
|
||||
if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" {
|
||||
return "enterprise_not_authorized"
|
||||
}
|
||||
if status.ErrorCode == "NO_AUTH" {
|
||||
return "no_auth"
|
||||
}
|
||||
@@ -1243,6 +1379,7 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1294,6 +1431,7 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -1341,6 +1479,7 @@ func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -37,12 +38,13 @@ var oauthHTTPClient = &http.Client{
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -149,6 +151,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
err error
|
||||
cliAuthDisabled bool
|
||||
denialReason string
|
||||
errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled
|
||||
}
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
errCh := make(chan error, 1)
|
||||
@@ -269,6 +272,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
cliAuthEnabled := denialReason == ""
|
||||
|
||||
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
|
||||
// the terminal so portal can update copy without releasing the CLI.
|
||||
serverMsg := ""
|
||||
if authStatus != nil {
|
||||
serverMsg = authStatus.ErrorMsg
|
||||
}
|
||||
|
||||
// Update CLI auth disabled state
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
@@ -283,6 +293,8 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = fmt.Fprint(w, accessDeniedHTML)
|
||||
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
|
||||
_, _ = fmt.Fprint(w, channelDeniedHTML)
|
||||
case denialReason == "enterprise_not_authorized":
|
||||
_, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg))
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
}
|
||||
@@ -292,7 +304,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
@@ -397,7 +409,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI)
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
@@ -443,6 +455,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
|
||||
case "channel_not_allowed", "channel_required":
|
||||
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
|
||||
case "enterprise_not_authorized":
|
||||
if msg := strings.TrimSpace(result.errorMsg); msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return nil, errors.New(i18n.T("本次请求未通过企业安全认证"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
@@ -547,9 +564,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
if rErr == nil {
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||
}
|
||||
} else {
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
||||
|
||||
@@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
if TokenDataExistsKeychain() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
@@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
@@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
tokenA := &TokenData{
|
||||
AccessToken: "access-a",
|
||||
RefreshToken: "refresh-a",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_a",
|
||||
CorpName: "A Org",
|
||||
ClientID: "client-a",
|
||||
}
|
||||
tokenB := &TokenData{
|
||||
AccessToken: "access-b",
|
||||
RefreshToken: "refresh-b",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
ClientID: "client-b",
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() after import error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profiles after import = %#v", cfg)
|
||||
}
|
||||
if len(cfg.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
|
||||
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "access-a" {
|
||||
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "access-b" {
|
||||
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
|
||||
// cross-process file lock) so that all read-modify-write cycles on
|
||||
// profiles.json and the legacy token mirror are serialized.
|
||||
//
|
||||
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
|
||||
// calling a public (locking) function from within fn would deadlock. Paths that
|
||||
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
|
||||
// reached from it) must likewise call the lock-free variants directly.
|
||||
func withProfilesLock(configDir string, fn func() error) error {
|
||||
lock, err := AcquireDualLock(context.Background(), configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Release()
|
||||
return fn()
|
||||
}
|
||||
|
||||
const profilesJSONFile = "profiles.json"
|
||||
|
||||
const (
|
||||
ProfileStatusActive = "active"
|
||||
ProfileStatusExpired = "expired"
|
||||
ProfileStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
|
||||
type ProfilesConfig struct {
|
||||
Version int `json:"version"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []Profile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// Profile is a logged-in DingTalk organization identity.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// SetRuntimeProfile sets a process-local one-shot profile override.
|
||||
func SetRuntimeProfile(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
func ProfilesPath(configDir string) string {
|
||||
return filepath.Join(configDir, profilesJSONFile)
|
||||
}
|
||||
|
||||
// LoadProfiles reads profiles.json. A missing file returns an empty config.
|
||||
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
|
||||
path := ProfilesPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profiles: %w", err)
|
||||
}
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
|
||||
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
|
||||
// instead of being permanently locked out by an unreadable profiles.json.
|
||||
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
|
||||
_ = os.Rename(path, quarantine)
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveProfiles writes profiles.json atomically.
|
||||
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
||||
if cfg == nil {
|
||||
cfg = &ProfilesConfig{}
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal profiles: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
path := ProfilesPath(configDir)
|
||||
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
|
||||
// concurrent writers interleave into the same temp file and rename a
|
||||
// corrupted result into place.
|
||||
tmp := path + "." + uuid.New().String() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("write profiles tmp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename profiles: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
|
||||
// EnsureProfilesMigration migrates a legacy single-slot token into the
|
||||
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
|
||||
// from contexts that already hold it (refresh / read paths).
|
||||
func EnsureProfilesMigration(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return ensureProfilesMigrationLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) > 0 {
|
||||
return nil
|
||||
}
|
||||
if !TokenDataExistsKeychain() {
|
||||
return nil
|
||||
}
|
||||
data, err := LoadTokenDataKeychain()
|
||||
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, false)
|
||||
}
|
||||
|
||||
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
||||
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
||||
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
||||
}
|
||||
|
||||
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
|
||||
// the token's corp the persistent current profile.
|
||||
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
|
||||
})
|
||||
}
|
||||
|
||||
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
|
||||
}
|
||||
|
||||
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
idx := profileIndexByCorpID(cfg, corpID)
|
||||
if idx < 0 {
|
||||
profile := Profile{
|
||||
Name: chooseProfileName(cfg, data),
|
||||
CorpID: corpID,
|
||||
CorpName: strings.TrimSpace(data.CorpName),
|
||||
UserID: strings.TrimSpace(data.UserID),
|
||||
UserName: strings.TrimSpace(data.UserName),
|
||||
ClientID: strings.TrimSpace(data.ClientID),
|
||||
Status: ProfileStatusActive,
|
||||
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
|
||||
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
|
||||
LastLoginAt: now,
|
||||
LastUsedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, profile)
|
||||
} else {
|
||||
p := &cfg.Profiles[idx]
|
||||
if shouldRefreshProfileName(p, data) {
|
||||
p.Name = chooseProfileName(cfg, data)
|
||||
}
|
||||
if v := strings.TrimSpace(data.CorpName); v != "" {
|
||||
p.CorpName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserID); v != "" {
|
||||
p.UserID = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserName); v != "" {
|
||||
p.UserName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.ClientID); v != "" {
|
||||
p.ClientID = v
|
||||
}
|
||||
p.Status = ProfileStatusActive
|
||||
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
|
||||
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
|
||||
p.LastLoginAt = now
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = corpID
|
||||
}
|
||||
if makeCurrent && cfg.CurrentProfile != corpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
|
||||
func ResolveProfile(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SetCurrentProfile persists the selected current profile.
|
||||
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := setCurrentProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
if cfg.CurrentProfile != p.CorpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = p.CorpID
|
||||
}
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// UsePreviousProfile toggles currentProfile and previousProfile.
|
||||
func UsePreviousProfile(configDir string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := usePreviousProfileLocked(configDir)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev := strings.TrimSpace(cfg.PreviousProfile)
|
||||
if prev == "" {
|
||||
return nil, fmt.Errorf("previous profile is empty")
|
||||
}
|
||||
p := findProfile(cfg, prev)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("previous profile %q not found", prev)
|
||||
}
|
||||
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// RemoveProfile removes a profile from metadata and returns the removed profile.
|
||||
func RemoveProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := removeProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
removed := *p
|
||||
kept := cfg.Profiles[:0]
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID != removed.CorpID {
|
||||
kept = append(kept, profile)
|
||||
}
|
||||
}
|
||||
cfg.Profiles = kept
|
||||
if cfg.PrimaryProfile == removed.CorpID {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == removed.CorpID {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.PreviousProfile == removed.CorpID {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
cfg.PrimaryProfile = ""
|
||||
cfg.CurrentProfile = ""
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &removed, nil
|
||||
}
|
||||
|
||||
// MarkProfileStatus updates a profile status if it exists.
|
||||
func MarkProfileStatus(configDir, corpID, status string) error {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return nil
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return markProfileStatusLocked(configDir, corpID, status)
|
||||
})
|
||||
}
|
||||
|
||||
func markProfileStatusLocked(configDir, corpID, status string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := findProfile(cfg, corpID)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Status = strings.TrimSpace(status)
|
||||
p.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
|
||||
func SyncLegacyTokenMirror(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return syncLegacyTokenMirrorLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func syncLegacyTokenMirrorLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hadReadError := false
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
p := findProfile(cfg, candidate)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
|
||||
if loadErr != nil {
|
||||
// Transient keychain read failure: do NOT touch the existing mirror.
|
||||
hadReadError = true
|
||||
continue
|
||||
}
|
||||
if data != nil {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
}
|
||||
if hadReadError {
|
||||
// Keep the existing legacy mirror untouched rather than wiping a host
|
||||
// app's login state just because keychain was momentarily unavailable.
|
||||
return nil
|
||||
}
|
||||
// All candidate profiles confirmed absent (no token): clear the mirror.
|
||||
_ = DeleteTokenDataKeychain()
|
||||
_ = DeleteTokenMarker(configDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.Version = 1
|
||||
seen := make(map[string]bool, len(cfg.Profiles))
|
||||
profiles := cfg.Profiles[:0]
|
||||
for _, p := range cfg.Profiles {
|
||||
p.CorpID = strings.TrimSpace(p.CorpID)
|
||||
if p.CorpID == "" || seen[p.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[p.CorpID] = true
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
p.Name = p.CorpID
|
||||
}
|
||||
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
|
||||
p.Name = corpName
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = ProfileStatusActive
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
cfg.Profiles = profiles
|
||||
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
|
||||
cfg.PrimaryProfile = ""
|
||||
}
|
||||
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
}
|
||||
}
|
||||
|
||||
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
|
||||
base := strings.TrimSpace(data.CorpName)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(data.CorpID)
|
||||
}
|
||||
if base == "" {
|
||||
base = "profile"
|
||||
}
|
||||
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
|
||||
return base
|
||||
}
|
||||
suffix := shortCorpID(data.CorpID)
|
||||
name := base + "-" + suffix
|
||||
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
|
||||
return name
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
|
||||
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
|
||||
if p == nil || data == nil {
|
||||
return false
|
||||
}
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for _, p := range cfg.Profiles {
|
||||
if p.CorpID != corpID && p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return nil
|
||||
}
|
||||
var corpNameMatch *Profile
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
|
||||
return &cfg.Profiles[i]
|
||||
}
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
||||
if corpNameMatch != nil {
|
||||
return nil
|
||||
}
|
||||
corpNameMatch = &cfg.Profiles[i]
|
||||
}
|
||||
}
|
||||
return corpNameMatch
|
||||
}
|
||||
|
||||
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
|
||||
if cfg == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func firstProfileCorpID(cfg *ProfilesConfig) string {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return cfg.Profiles[0].CorpID
|
||||
}
|
||||
|
||||
func touchProfile(cfg *ProfilesConfig, corpID string) {
|
||||
if p := findProfile(cfg, corpID); p != nil {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func timeOrRFC3339(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func shortCorpID(corpID string) string {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if len(corpID) <= 8 {
|
||||
return corpID
|
||||
}
|
||||
return corpID[len(corpID)-8:]
|
||||
}
|
||||
+167
-10
@@ -22,8 +22,11 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
|
||||
|
||||
// DeleteTokenMarker removes the token.json marker file.
|
||||
func DeleteTokenMarker(configDir string) error {
|
||||
return os.Remove(filepath.Join(configDir, tokenJSONFile))
|
||||
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
||||
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
|
||||
// to the default keychain-based storage.
|
||||
func SaveTokenData(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
return SaveTokenDataKeychain(data)
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return saveTokenDataLocked(configDir, data)
|
||||
})
|
||||
}
|
||||
|
||||
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
|
||||
// writes assuming the auth dual-layer lock is already held. Callers that
|
||||
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
|
||||
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
|
||||
// to avoid deadlocking on the non-reentrant lock.
|
||||
func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
if data != nil && strings.TrimSpace(data.CorpID) != "" {
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
|
||||
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
if makeCurrent {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
|
||||
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
}
|
||||
|
||||
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain with legacy .data migration.
|
||||
func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
return LoadTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
|
||||
// currentProfile. Empty selector follows the default resolution chain.
|
||||
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if h := edition.Get(); h.LoadToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
jsonData, err := h.LoadToken(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// Default: keychain with legacy .data migration
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected != nil {
|
||||
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
// profile. Only fall back to the legacy single slot when it belongs to
|
||||
// the SAME org; otherwise surface the error instead of silently acting
|
||||
// as a different organization (the legacy mirror may have drifted).
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if TokenDataExistsKeychain() {
|
||||
return LoadTokenDataKeychain()
|
||||
}
|
||||
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err == nil {
|
||||
// One-time legacy secure-store -> keychain migration. This read path may run
|
||||
// while the refresh lock is already held, so use the lock-free saver.
|
||||
if err := saveTokenDataLocked(configDir, data); err == nil {
|
||||
_ = DeleteSecureData(configDir)
|
||||
}
|
||||
return data, nil
|
||||
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain + legacy cleanup.
|
||||
func DeleteTokenData(configDir string) error {
|
||||
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
|
||||
// removes the current/default profile, falling back to legacy single-slot auth.
|
||||
func DeleteTokenDataForProfile(configDir, profile string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return deleteTokenDataForProfileLocked(configDir, profile)
|
||||
})
|
||||
}
|
||||
|
||||
func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selected != nil {
|
||||
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
|
||||
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
|
||||
legacyErr := syncLegacyTokenMirrorLocked(configDir)
|
||||
secureErr := DeleteSecureData(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
if removeErr != nil {
|
||||
return removeErr
|
||||
}
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return secureErr
|
||||
}
|
||||
|
||||
keychainErr := DeleteTokenDataKeychain()
|
||||
legacyErr := DeleteSecureData(configDir)
|
||||
markerErr := DeleteTokenMarker(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
return legacyErr
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return markerErr
|
||||
}
|
||||
|
||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||
func DeleteAllTokenData(configDir string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
var firstErr error
|
||||
// Best-effort: even if profiles.json is unreadable, still clear every
|
||||
// other slot so the user can always self-heal via auth reset / logout.
|
||||
if cfg, err := LoadProfiles(configDir); err == nil {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
|
||||
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
|
||||
for _, m := range matches {
|
||||
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
return firstErr
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,8 +26,10 @@ import (
|
||||
// written by these tests, and removes test data on completion.
|
||||
func cleanupKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
SetRuntimeProfile("")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
SetRuntimeProfile("")
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
}
|
||||
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
|
||||
loadedB, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
loadedA, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after switch error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
if _, err := UsePreviousProfile(configDir); err != nil {
|
||||
t.Fatalf("UsePreviousProfile() error = %v", err)
|
||||
}
|
||||
loadedB, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after previous error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp_b")
|
||||
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
|
||||
}
|
||||
SetRuntimeProfile("")
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b_refreshed" {
|
||||
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
|
||||
}
|
||||
loadedDefault, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedDefault.AccessToken != "at_a" {
|
||||
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
|
||||
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
|
||||
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("profiles after delete = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "旧组织名")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
second.UserID = "user_updated"
|
||||
second.UserName = "Updated User"
|
||||
second.ClientID = "client_updated"
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
profile := cfg.Profiles[0]
|
||||
if profile.CorpName != "新组织名" {
|
||||
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
|
||||
}
|
||||
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
|
||||
t.Fatalf("profile metadata was not overwritten: %#v", profile)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_second" {
|
||||
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
|
||||
resolved, err := ResolveProfile(configDir, "新组织名")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfile(corpName) error = %v", err)
|
||||
}
|
||||
if resolved.CorpID != "corp_same" {
|
||||
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
raw := `{
|
||||
"version": 1,
|
||||
"primaryProfile": "corp_same",
|
||||
"currentProfile": "corp_same",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "corp_same",
|
||||
"corpId": "corp_same",
|
||||
"corpName": "新组织名"
|
||||
}
|
||||
]
|
||||
}`
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profiles.json) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
|
||||
if err := SaveTokenDataKeychain(legacy); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_legacy" {
|
||||
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
|
||||
t.Fatalf("profile pointers after migration = %#v", cfg)
|
||||
}
|
||||
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
|
||||
t.Fatal("corp-scoped token should exist after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
|
||||
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testToken(accessToken, corpID, corpName string) *TokenData {
|
||||
now := time.Now().UTC()
|
||||
return &TokenData{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "rt_" + accessToken,
|
||||
ExpiresAt: now.Add(2 * time.Hour),
|
||||
RefreshExpAt: now.Add(30 * 24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: "user_" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client_" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidityChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Vendored
-26
@@ -1,26 +0,0 @@
|
||||
package cache
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
|
||||
// ChangedServerKeysByUpdatedAt returns the set of live server keys that should
|
||||
// be refreshed because they are new or their existing registry updatedAt value
|
||||
// changed. This intentionally uses only existing market registry metadata.
|
||||
func ChangedServerKeysByUpdatedAt(cached, live []market.ServerDescriptor) map[string]bool {
|
||||
cachedByKey := make(map[string]market.ServerDescriptor, len(cached))
|
||||
for _, server := range cached {
|
||||
cachedByKey[server.Key] = server
|
||||
}
|
||||
|
||||
changed := make(map[string]bool)
|
||||
for _, server := range live {
|
||||
previous, ok := cachedByKey[server.Key]
|
||||
if !ok {
|
||||
changed[server.Key] = true
|
||||
continue
|
||||
}
|
||||
if !server.UpdatedAt.Equal(previous.UpdatedAt) {
|
||||
changed[server.Key] = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
Vendored
-393
@@ -1,393 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// HasActionVersionChanged compares cached actionVersion strings against the
|
||||
// versions reported by a fresh Detail API response. It returns true when at
|
||||
// least one tool's version has changed, signalling that the tools cache
|
||||
// should be refreshed even if the TTL has not expired.
|
||||
func HasActionVersionChanged(cached map[string]string, detailTools []market.DetailTool) bool {
|
||||
if len(cached) == 0 {
|
||||
return false // no prior version data → not a change
|
||||
}
|
||||
for _, tool := range detailTools {
|
||||
name := strings.TrimSpace(tool.ToolName)
|
||||
version := strings.TrimSpace(tool.ActionVersion)
|
||||
if name == "" || version == "" {
|
||||
continue
|
||||
}
|
||||
if oldVersion, exists := cached[name]; exists && oldVersion != version {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractActionVersions builds a tool-name → actionVersion map from detail tools.
|
||||
func ExtractActionVersions(detailTools []market.DetailTool) map[string]string {
|
||||
if len(detailTools) == 0 {
|
||||
return nil
|
||||
}
|
||||
versions := make(map[string]string, len(detailTools))
|
||||
for _, tool := range detailTools {
|
||||
name := strings.TrimSpace(tool.ToolName)
|
||||
version := strings.TrimSpace(tool.ActionVersion)
|
||||
if name != "" && version != "" {
|
||||
versions[name] = version
|
||||
}
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
const (
|
||||
RegistryTTL = 24 * time.Hour
|
||||
ToolsTTL = 7 * 24 * time.Hour
|
||||
DetailTTL = 7 * 24 * time.Hour
|
||||
RevalidateAfter = 1 * time.Hour
|
||||
)
|
||||
|
||||
type Freshness string
|
||||
|
||||
const (
|
||||
FreshnessFresh Freshness = "fresh"
|
||||
FreshnessStale Freshness = "stale"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
Root string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type RegistrySnapshot struct {
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
Servers []market.ServerDescriptor `json:"servers"`
|
||||
}
|
||||
|
||||
type ToolsSnapshot struct {
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
ServerKey string `json:"server_key"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Tools []transport.ToolDescriptor `json:"tools"`
|
||||
ActionVersions map[string]string `json:"action_versions,omitempty"`
|
||||
}
|
||||
|
||||
type DetailSnapshot struct {
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
MCPID int `json:"mcp_id"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
func NewStore(root string) *Store {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
root = defaultCacheRoot()
|
||||
}
|
||||
return &Store{
|
||||
Root: root,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultCacheRoot returns a stable, persistent cache directory.
|
||||
// Prefers ~/.dws/cache (matches defaultConfigDir in app/config.go).
|
||||
// Falls back to os.TempDir()/dws-cache only when $HOME is unavailable.
|
||||
func defaultCacheRoot() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".dws", "cache")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "dws-cache")
|
||||
}
|
||||
|
||||
func (s *Store) SaveRegistry(partition string, snapshot RegistrySnapshot) error {
|
||||
if snapshot.SavedAt.IsZero() {
|
||||
snapshot.SavedAt = s.Now().UTC()
|
||||
}
|
||||
return s.saveJSON(s.registryPath(partition), snapshot)
|
||||
}
|
||||
|
||||
func (s *Store) LoadRegistry(partition string) (RegistrySnapshot, Freshness, error) {
|
||||
var snapshot RegistrySnapshot
|
||||
if err := s.loadJSON(s.registryPath(partition), &snapshot); err != nil {
|
||||
return RegistrySnapshot{}, "", err
|
||||
}
|
||||
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, RegistryTTL), nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveTools(partition, serverKey string, snapshot ToolsSnapshot) error {
|
||||
if snapshot.SavedAt.IsZero() {
|
||||
snapshot.SavedAt = s.Now().UTC()
|
||||
}
|
||||
return s.saveJSON(s.toolsPath(partition, serverKey), snapshot)
|
||||
}
|
||||
|
||||
func (s *Store) LoadTools(partition, serverKey string) (ToolsSnapshot, Freshness, error) {
|
||||
var snapshot ToolsSnapshot
|
||||
if err := s.loadJSON(s.toolsPath(partition, serverKey), &snapshot); err != nil {
|
||||
return ToolsSnapshot{}, "", err
|
||||
}
|
||||
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, ToolsTTL), nil
|
||||
}
|
||||
|
||||
// DeleteTools removes the cached tools snapshot for a server, forcing a
|
||||
// re-fetch on the next DiscoverServerRuntime call.
|
||||
func (s *Store) DeleteTools(partition, serverKey string) error {
|
||||
path := s.toolsPath(partition, serverKey)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToolsCacheEntrySummary summarises one cached tools snapshot.
|
||||
type ToolsCacheEntrySummary struct {
|
||||
ServerKey string `json:"server_key"`
|
||||
Freshness Freshness `json:"freshness"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
ToolCount int `json:"tool_count"`
|
||||
TTLRemaining string `json:"ttl_remaining"`
|
||||
}
|
||||
|
||||
// ListToolsCacheEntries walks the cache directory and returns a summary for
|
||||
// each server whose tools snapshot is cached.
|
||||
func (s *Store) ListToolsCacheEntries(partition string) ([]ToolsCacheEntrySummary, error) {
|
||||
toolsDir := filepath.Join(s.Root, sanitize(partition), "tools")
|
||||
entries, err := os.ReadDir(toolsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.Now().UTC()
|
||||
summaries := make([]ToolsCacheEntrySummary, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
var snapshot ToolsSnapshot
|
||||
path := filepath.Join(toolsDir, entry.Name())
|
||||
if loadErr := s.loadJSON(path, &snapshot); loadErr != nil {
|
||||
continue
|
||||
}
|
||||
f := freshness(now, snapshot.SavedAt, ToolsTTL)
|
||||
remaining := ""
|
||||
if f == FreshnessFresh {
|
||||
rem := ToolsTTL - now.Sub(snapshot.SavedAt)
|
||||
if rem > 0 {
|
||||
remaining = rem.Truncate(time.Minute).String()
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, ToolsCacheEntrySummary{
|
||||
ServerKey: snapshot.ServerKey,
|
||||
Freshness: f,
|
||||
SavedAt: snapshot.SavedAt,
|
||||
ToolCount: len(snapshot.Tools),
|
||||
TTLRemaining: remaining,
|
||||
})
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveDetail(partition, serverKey string, snapshot DetailSnapshot) error {
|
||||
if snapshot.SavedAt.IsZero() {
|
||||
snapshot.SavedAt = s.Now().UTC()
|
||||
}
|
||||
return s.saveJSON(s.detailPath(partition, serverKey), snapshot)
|
||||
}
|
||||
|
||||
func (s *Store) LoadDetail(partition, serverKey string) (DetailSnapshot, Freshness, error) {
|
||||
var snapshot DetailSnapshot
|
||||
if err := s.loadJSON(s.detailPath(partition, serverKey), &snapshot); err != nil {
|
||||
return DetailSnapshot{}, "", err
|
||||
}
|
||||
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, DetailTTL), nil
|
||||
}
|
||||
|
||||
// DeleteDetail removes the cached detail snapshot for a server.
|
||||
func (s *Store) DeleteDetail(partition, serverKey string) error {
|
||||
path := s.detailPath(partition, serverKey)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuarantinePartition moves the entire on-disk cache for a partition aside,
|
||||
// renaming it to "<partition>.quarantined", so the next load starts from an
|
||||
// empty cache while the poisoned snapshot stays on disk for inspection.
|
||||
// Returns the quarantine path, or "" when the partition has no cache on disk.
|
||||
// A previous quarantine for the same partition is replaced, so repeated
|
||||
// quarantines never accumulate.
|
||||
func (s *Store) QuarantinePartition(partition string) (string, error) {
|
||||
dir := filepath.Join(s.Root, sanitize(partition))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
quarantine := dir + ".quarantined"
|
||||
if err := os.RemoveAll(quarantine); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(dir, quarantine); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return quarantine, nil
|
||||
}
|
||||
|
||||
// discoverySubdirs are the per-partition directories holding discovery-derived
|
||||
// data: the market registry envelope plus tools / detail snapshots.
|
||||
var discoverySubdirs = []string{"market", "tools", "detail"}
|
||||
|
||||
// PurgeDiscoveryData deletes the discovery-derived cache for every partition
|
||||
// under the cache root, leaving unrelated data that shares the root (e.g. the
|
||||
// upgrade download cache in "downloads/") untouched. Returns the names of the
|
||||
// partition directories that had data removed. Removal errors are collected
|
||||
// into the returned error but do not stop the sweep.
|
||||
func (s *Store) PurgeDiscoveryData() ([]string, error) {
|
||||
entries, err := os.ReadDir(s.Root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var purged []string
|
||||
var firstErr error
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
removedAny := false
|
||||
for _, sub := range discoverySubdirs {
|
||||
dir := filepath.Join(s.Root, entry.Name(), sub)
|
||||
if _, statErr := os.Stat(dir); statErr != nil {
|
||||
continue
|
||||
}
|
||||
if rmErr := os.RemoveAll(dir); rmErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = rmErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
removedAny = true
|
||||
}
|
||||
if removedAny {
|
||||
purged = append(purged, entry.Name())
|
||||
}
|
||||
}
|
||||
return purged, firstErr
|
||||
}
|
||||
|
||||
func (s *Store) registryPath(partition string) string {
|
||||
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
|
||||
}
|
||||
|
||||
func (s *Store) toolsPath(partition, serverKey string) string {
|
||||
return filepath.Join(s.Root, sanitize(partition), "tools", sanitize(serverKey)+".json")
|
||||
}
|
||||
|
||||
func (s *Store) detailPath(partition, serverKey string) string {
|
||||
return filepath.Join(s.Root, sanitize(partition), "detail", sanitize(serverKey)+".json")
|
||||
}
|
||||
|
||||
func (s *Store) saveJSON(path string, value any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic write with fsync to ensure data durability
|
||||
tmpPath := path + ".tmp"
|
||||
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeSuccess := false
|
||||
defer func() {
|
||||
if !writeSuccess {
|
||||
tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
writeSuccess = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadJSON(path string, out any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func freshness(now, savedAt time.Time, ttl time.Duration) Freshness {
|
||||
if savedAt.IsZero() || now.Sub(savedAt) > ttl {
|
||||
return FreshnessStale
|
||||
}
|
||||
return FreshnessFresh
|
||||
}
|
||||
|
||||
// ShouldRevalidate reports whether a still-valid snapshot is old enough to
|
||||
// merit a live revalidation attempt before trusting it as the current truth.
|
||||
func ShouldRevalidate(now, savedAt time.Time) bool {
|
||||
if savedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return now.Sub(savedAt) >= RevalidateAfter
|
||||
}
|
||||
|
||||
func sanitize(value string) string {
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func IsNotExist(err error) bool {
|
||||
return errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuarantinePartitionNoCacheIsNoop(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if path != "" {
|
||||
t.Errorf("QuarantinePartition() = %q, want empty path when nothing is cached", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantinePartitionMovesCacheAside(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
if err := s.SaveTools("default_default", "srv", ToolsSnapshot{ServerKey: "srv"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("QuarantinePartition() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(tmp, "default_default.quarantined")
|
||||
if path != want {
|
||||
t.Errorf("QuarantinePartition() = %q, want %q", path, want)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "default_default")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("original partition dir still present after quarantine (stat err = %v)", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "srv.json")); statErr != nil {
|
||||
t.Errorf("quarantined snapshot missing: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantinePartitionReplacesPreviousQuarantine(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
if err := s.SaveTools("default_default", "first", ToolsSnapshot{ServerKey: "first"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
if _, err := s.QuarantinePartition("default_default"); err != nil {
|
||||
t.Fatalf("first QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if err := s.SaveTools("default_default", "second", ToolsSnapshot{ServerKey: "second"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("second QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "second.json")); statErr != nil {
|
||||
t.Errorf("latest quarantine missing newest snapshot: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "first.json")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("previous quarantine was not replaced (stat err = %v)", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeDiscoveryDataRemovesDiscoveryDirsOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
|
||||
mustWrite := func(parts ...string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(parts...)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
mustWrite(tmp, "default_default", "market", "servers.json")
|
||||
mustWrite(tmp, "default_default", "tools", "srv.json")
|
||||
mustWrite(tmp, "default_default", "detail", "srv.json")
|
||||
mustWrite(tmp, "wukong_default", "tools", "srv.json")
|
||||
// Unrelated data sharing the cache root must survive the purge.
|
||||
mustWrite(tmp, "downloads", "dws-1.0.36.tar.gz")
|
||||
|
||||
purged, err := s.PurgeDiscoveryData()
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeDiscoveryData() error = %v", err)
|
||||
}
|
||||
if len(purged) != 2 {
|
||||
t.Fatalf("PurgeDiscoveryData() purged = %v, want 2 partitions", purged)
|
||||
}
|
||||
for _, sub := range []string{"market", "tools", "detail"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "default_default", sub)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s dir survived the purge (stat err = %v)", sub, statErr)
|
||||
}
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "wukong_default", "tools")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("second partition tools dir survived the purge (stat err = %v)", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "downloads", "dws-1.0.36.tar.gz")); statErr != nil {
|
||||
t.Errorf("unrelated downloads data was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeDiscoveryDataMissingRootIsNoop(t *testing.T) {
|
||||
s := NewStore(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
purged, err := s.PurgeDiscoveryData()
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeDiscoveryData() error = %v", err)
|
||||
}
|
||||
if len(purged) != 0 {
|
||||
t.Errorf("PurgeDiscoveryData() purged = %v, want none", purged)
|
||||
}
|
||||
}
|
||||
Vendored
-387
@@ -1,387 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestRegistrySnapshotFreshness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
|
||||
store := NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
err := store.SaveRegistry("tenant/default", RegistrySnapshot{
|
||||
SavedAt: now,
|
||||
Servers: []market.ServerDescriptor{{Key: "doc", DisplayName: "文档"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
_, freshness, err := store.LoadRegistry("tenant/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() error = %v", err)
|
||||
}
|
||||
if freshness != FreshnessFresh {
|
||||
t.Fatalf("LoadRegistry() freshness = %s, want %s", freshness, FreshnessFresh)
|
||||
}
|
||||
|
||||
store.Now = func() time.Time { return now.Add(25 * time.Hour) }
|
||||
_, freshness, err = store.LoadRegistry("tenant/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() stale error = %v", err)
|
||||
}
|
||||
if freshness != FreshnessStale {
|
||||
t.Fatalf("LoadRegistry() stale freshness = %s, want %s", freshness, FreshnessStale)
|
||||
}
|
||||
|
||||
if _, err := filepath.Abs(root); err != nil {
|
||||
t.Fatalf("unexpected temp dir error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsSnapshotRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
err := store.SaveTools("tenant/default", "doc", ToolsSnapshot{
|
||||
ServerKey: "doc",
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "create_document", Title: "创建文档"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
snapshot, _, err := store.LoadTools("tenant/default", "doc")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools() error = %v", err)
|
||||
}
|
||||
if snapshot.ProtocolVersion != "2025-03-26" {
|
||||
t.Fatalf("LoadTools() protocol = %q, want 2025-03-26", snapshot.ProtocolVersion)
|
||||
}
|
||||
if len(snapshot.Tools) != 1 {
|
||||
t.Fatalf("LoadTools() len = %d, want 1", len(snapshot.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSnapshotUsesDetailTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
|
||||
store := NewStore(t.TempDir())
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
if err := store.SaveDetail("tenant/default", "doc", DetailSnapshot{
|
||||
SavedAt: now,
|
||||
MCPID: 9629,
|
||||
Payload: []byte(`{"success":true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDetail() error = %v", err)
|
||||
}
|
||||
|
||||
_, freshness, err := store.LoadDetail("tenant/default", "doc")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail() error = %v", err)
|
||||
}
|
||||
if freshness != FreshnessFresh {
|
||||
t.Fatalf("LoadDetail() freshness = %s, want %s", freshness, FreshnessFresh)
|
||||
}
|
||||
|
||||
store.Now = func() time.Time { return now.Add(DetailTTL + time.Hour) }
|
||||
_, freshness, err = store.LoadDetail("tenant/default", "doc")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDetail() stale error = %v", err)
|
||||
}
|
||||
if freshness != FreshnessStale {
|
||||
t.Fatalf("LoadDetail() stale freshness = %s, want %s", freshness, FreshnessStale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasActionVersionChanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cached map[string]string
|
||||
detail []market.DetailTool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil cached returns false",
|
||||
cached: nil,
|
||||
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty cached returns false",
|
||||
cached: map[string]string{},
|
||||
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "versions match returns false",
|
||||
cached: map[string]string{"foo": "v1"},
|
||||
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v1"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "version changed returns true",
|
||||
cached: map[string]string{"foo": "v1"},
|
||||
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: "v2"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "new tool not in cache returns false",
|
||||
cached: map[string]string{"foo": "v1"},
|
||||
detail: []market.DetailTool{
|
||||
{ToolName: "foo", ActionVersion: "v1"},
|
||||
{ToolName: "bar", ActionVersion: "v1"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "detail with empty version skipped",
|
||||
cached: map[string]string{"foo": "v1"},
|
||||
detail: []market.DetailTool{{ToolName: "foo", ActionVersion: ""}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "detail with empty name skipped",
|
||||
cached: map[string]string{"foo": "v1"},
|
||||
detail: []market.DetailTool{{ToolName: "", ActionVersion: "v2"}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := HasActionVersionChanged(tt.cached, tt.detail)
|
||||
if got != tt.want {
|
||||
t.Fatalf("HasActionVersionChanged() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractActionVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tools []market.DetailTool
|
||||
expect map[string]string
|
||||
}{
|
||||
{
|
||||
name: "nil tools returns nil",
|
||||
tools: nil,
|
||||
expect: nil,
|
||||
},
|
||||
{
|
||||
name: "empty tools returns nil",
|
||||
tools: []market.DetailTool{},
|
||||
expect: nil,
|
||||
},
|
||||
{
|
||||
name: "extracts versions",
|
||||
tools: []market.DetailTool{
|
||||
{ToolName: "create_doc", ActionVersion: "G-ACT-100"},
|
||||
{ToolName: "search_doc", ActionVersion: "G-ACT-101"},
|
||||
},
|
||||
expect: map[string]string{
|
||||
"create_doc": "G-ACT-100",
|
||||
"search_doc": "G-ACT-101",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips empty version",
|
||||
tools: []market.DetailTool{
|
||||
{ToolName: "create_doc", ActionVersion: "G-ACT-100"},
|
||||
{ToolName: "legacy_tool", ActionVersion: ""},
|
||||
},
|
||||
expect: map[string]string{
|
||||
"create_doc": "G-ACT-100",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all empty returns nil",
|
||||
tools: []market.DetailTool{
|
||||
{ToolName: "", ActionVersion: "G-ACT-100"},
|
||||
{ToolName: "tool", ActionVersion: ""},
|
||||
},
|
||||
expect: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ExtractActionVersions(tt.tools)
|
||||
if len(got) != len(tt.expect) {
|
||||
t.Fatalf("ExtractActionVersions() len = %d, want %d", len(got), len(tt.expect))
|
||||
}
|
||||
for k, v := range tt.expect {
|
||||
if got[k] != v {
|
||||
t.Fatalf("ExtractActionVersions()[%q] = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsSnapshotActionVersionsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
versions := map[string]string{
|
||||
"create_doc": "G-ACT-100",
|
||||
"search_doc": "G-ACT-101",
|
||||
}
|
||||
err := store.SaveTools("tenant/default", "doc", ToolsSnapshot{
|
||||
ServerKey: "doc",
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "create_doc", Title: "创建文档"},
|
||||
},
|
||||
ActionVersions: versions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
snapshot, _, err := store.LoadTools("tenant/default", "doc")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools() error = %v", err)
|
||||
}
|
||||
if len(snapshot.ActionVersions) != 2 {
|
||||
t.Fatalf("ActionVersions len = %d, want 2", len(snapshot.ActionVersions))
|
||||
}
|
||||
if snapshot.ActionVersions["create_doc"] != "G-ACT-100" {
|
||||
t.Fatalf("ActionVersions[create_doc] = %q, want G-ACT-100", snapshot.ActionVersions["create_doc"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
partition := "tenant/default"
|
||||
|
||||
err := store.SaveTools(partition, "doc", ToolsSnapshot{
|
||||
ServerKey: "doc",
|
||||
Tools: []transport.ToolDescriptor{{Name: "create_doc"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteTools(partition, "doc"); err != nil {
|
||||
t.Fatalf("DeleteTools() error = %v", err)
|
||||
}
|
||||
|
||||
_, _, err = store.LoadTools(partition, "doc")
|
||||
if err == nil {
|
||||
t.Fatal("LoadTools() should fail after DeleteTools()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteToolsNonExistent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
if err := store.DeleteTools("tenant/default", "nonexistent"); err != nil {
|
||||
t.Fatalf("DeleteTools(nonexistent) should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListToolsCacheEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := NewStore(t.TempDir())
|
||||
store.Now = func() time.Time { return now }
|
||||
partition := "tenant/default"
|
||||
|
||||
// Save two server tools snapshots
|
||||
_ = store.SaveTools(partition, "doc", ToolsSnapshot{
|
||||
ServerKey: "doc",
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "create_doc"},
|
||||
{Name: "search_doc"},
|
||||
},
|
||||
})
|
||||
_ = store.SaveTools(partition, "calendar", ToolsSnapshot{
|
||||
ServerKey: "calendar",
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "list_events"},
|
||||
},
|
||||
})
|
||||
|
||||
entries, err := store.ListToolsCacheEntries(partition)
|
||||
if err != nil {
|
||||
t.Fatalf("ListToolsCacheEntries() error = %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("ListToolsCacheEntries() len = %d, want 2", len(entries))
|
||||
}
|
||||
|
||||
byKey := make(map[string]ToolsCacheEntrySummary, len(entries))
|
||||
for _, e := range entries {
|
||||
byKey[e.ServerKey] = e
|
||||
}
|
||||
|
||||
doc, ok := byKey["doc"]
|
||||
if !ok {
|
||||
t.Fatal("missing 'doc' in ListToolsCacheEntries()")
|
||||
}
|
||||
if doc.Freshness != FreshnessFresh {
|
||||
t.Fatalf("doc freshness = %s, want %s", doc.Freshness, FreshnessFresh)
|
||||
}
|
||||
if doc.ToolCount != 2 {
|
||||
t.Fatalf("doc tool_count = %d, want 2", doc.ToolCount)
|
||||
}
|
||||
|
||||
cal, ok := byKey["calendar"]
|
||||
if !ok {
|
||||
t.Fatal("missing 'calendar' in ListToolsCacheEntries()")
|
||||
}
|
||||
if cal.ToolCount != 1 {
|
||||
t.Fatalf("calendar tool_count = %d, want 1", cal.ToolCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListToolsCacheEntriesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := NewStore(t.TempDir())
|
||||
entries, err := store.ListToolsCacheEntries("nonexistent/partition")
|
||||
if err != nil {
|
||||
t.Fatalf("ListToolsCacheEntries() error = %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("ListToolsCacheEntries() len = %d, want 0", len(entries))
|
||||
}
|
||||
}
|
||||
+31
-824
@@ -14,24 +14,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/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"
|
||||
)
|
||||
|
||||
@@ -58,80 +49,33 @@ type FlagSpec struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
catalog, loadErr := loader.Load(ctx)
|
||||
|
||||
longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp."
|
||||
if loadErr != nil {
|
||||
longDescription += fmt.Sprintf("\n\nDiscovery note: %v", loadErr)
|
||||
}
|
||||
if len(catalog.Products) == 0 {
|
||||
longDescription += "\n\nNo canonical products are currently loaded. Set DWS_CATALOG_FIXTURE to populate the surface."
|
||||
}
|
||||
|
||||
// NewMCPCommand returns a stub command since the canonical discovery
|
||||
// surface has been removed. The command tree is now built from plugins
|
||||
// and static endpoint registration only.
|
||||
func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Canonical MCP-derived CLI surface",
|
||||
Long: longDescription,
|
||||
Hidden: false,
|
||||
Short: "Canonical MCP-derived CLI surface (static mode)",
|
||||
Long: "The canonical MCP command surface is disabled. Commands are now registered via plugins and static endpoints.",
|
||||
Hidden: true,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
if loadErr != nil {
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return loadErr
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
for _, product := range catalog.Products {
|
||||
if product.CLI != nil && product.CLI.Skip {
|
||||
continue
|
||||
}
|
||||
productCommand := newProductCommand(product, runner, engine)
|
||||
cmd.AddCommand(productCommand)
|
||||
addGroupedProductAlias(cmd, product, runner, engine)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
// NewSchemaCommand returns a stub schema command since the canonical
|
||||
// catalog discovery has been removed.
|
||||
func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
|
||||
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
|
||||
不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
|
||||
输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP
|
||||
注解和 CLI 层的 flag overlay(alias/transform/env_default)。
|
||||
|
||||
路径支持三种写法:
|
||||
product.rpc_name 规范路径 (e.g. ding.send_ding_message)
|
||||
product.group.cli_name CLI 点路径 (e.g. ding.message.send)
|
||||
"product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send")
|
||||
|
||||
示例:
|
||||
dws schema # 列出所有产品
|
||||
dws schema ding.send_ding_message # 规范路径
|
||||
dws schema "ding message send" # CLI 路径(空格)
|
||||
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
|
||||
dws schema calendar.create_event --jq '.tool.auth'
|
||||
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
|
||||
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay
|
||||
|
||||
helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app
|
||||
MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required,
|
||||
键为 CLI flag):
|
||||
dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat)
|
||||
dws schema "dev app" # 列出该分组下的子命令`,
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -144,82 +88,28 @@ MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery
|
||||
// catalog; their schema CONTENT is fetched LIVE from the helper's
|
||||
// pinned MCP server (op-app) and rendered in the gws-flat shape, so
|
||||
// `dws schema "dev app robot config"` answers without touching
|
||||
// discovery. Only the `dev` root claims this path; everything else
|
||||
// falls through to the catalog below.
|
||||
if len(args) > 0 {
|
||||
// Helper-only subtrees support.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
payload,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
var degraded *CatalogDegraded
|
||||
if errors.As(err, °raded) {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "hint: %s\n", degraded.Hint)
|
||||
payload := map[string]any{
|
||||
"kind": "schema",
|
||||
"count": 0,
|
||||
"products": []any{},
|
||||
"degraded": true,
|
||||
"reason": string(degraded.Reason),
|
||||
"hint": degraded.Hint,
|
||||
}
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
payload,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := schemaPayload(catalog, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append helper-only subtrees (e.g. `dev`) to the no-arg product
|
||||
// listing so browsing all products also surfaces helper commands.
|
||||
if len(args) == 0 {
|
||||
if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 {
|
||||
if products, ok := payload["products"].([]map[string]any); ok {
|
||||
payload["products"] = append(products, helpers...)
|
||||
payload["count"] = len(payload["products"].([]map[string]any))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
payload,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
|
||||
func BuildFlagSpecs(schema map[string]any, hints map[string]CLIFlagHint) []FlagSpec {
|
||||
properties, ok := nestedMap(schema, "properties")
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -255,332 +145,8 @@ func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []Fl
|
||||
return specs
|
||||
}
|
||||
|
||||
func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
shortDescription := product.DisplayName
|
||||
if strings.TrimSpace(product.Description) != "" {
|
||||
shortDescription = product.Description
|
||||
}
|
||||
if shortDescription == "" {
|
||||
shortDescription = product.ID
|
||||
}
|
||||
aliases := make([]string, 0, 2)
|
||||
seenAlias := map[string]bool{product.ID: true}
|
||||
addAlias := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seenAlias[s] {
|
||||
return
|
||||
}
|
||||
seenAlias[s] = true
|
||||
aliases = append(aliases, s)
|
||||
}
|
||||
if preferred := preferredProductRouteToken(product); preferred != "" {
|
||||
addAlias(preferred)
|
||||
}
|
||||
// Consume only cli.Aliases (canonical alternate-name field).
|
||||
// cli.Prefixes is the tool-name-prefix pool consumed by deriveCommandName;
|
||||
// treating prefixes[1:] as aliases over-registers names the wukong edition
|
||||
// does not expose, breaking cross-edition parity.
|
||||
if product.CLI != nil {
|
||||
for _, a := range product.CLI.Aliases {
|
||||
addAlias(a)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: product.ID,
|
||||
Aliases: aliases,
|
||||
Short: shortDescription,
|
||||
Hidden: product.CLI != nil && product.CLI.Hidden,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
if product.CLI != nil && strings.TrimSpace(product.CLI.Group) != "" {
|
||||
cmd.Long = fmt.Sprintf("%s\n\nGroup: %s", shortDescription, product.CLI.Group)
|
||||
}
|
||||
if warning := lifecycleWarning(product); warning != "" {
|
||||
if strings.TrimSpace(cmd.Long) == "" {
|
||||
cmd.Long = shortDescription
|
||||
}
|
||||
cmd.Long = strings.TrimSpace(cmd.Long + "\n\nLifecycle: " + warning)
|
||||
}
|
||||
|
||||
for _, tool := range product.Tools {
|
||||
cmd.AddCommand(newToolCommand(product, tool, runner, engine))
|
||||
}
|
||||
|
||||
// Register phase: notify the pipeline that a product and its
|
||||
// tools have been added to the command tree. This runs once at
|
||||
// startup (not per-request) and enables handlers to inspect or
|
||||
// enrich the registered command surface.
|
||||
if engine != nil && engine.HasHandlers(pipeline.Register) {
|
||||
pctx := &pipeline.Context{
|
||||
Command: product.ID,
|
||||
}
|
||||
// Best-effort — registration errors are logged but do not
|
||||
// prevent the CLI from starting.
|
||||
if pipeErr := engine.RunPhase(pipeline.Register, pctx); pipeErr != nil {
|
||||
slog.Debug("pipeline register phase", "product", product.ID, "error", pipeErr)
|
||||
} else {
|
||||
slog.Debug("pipeline register",
|
||||
"product", product.ID,
|
||||
"tool_count", len(product.Tools),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) {
|
||||
if root == nil || product.CLI == nil {
|
||||
return
|
||||
}
|
||||
|
||||
groupPath := splitRouteTokens(product.CLI.Group)
|
||||
if len(groupPath) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
commandPath := splitRouteTokens(product.CLI.Command)
|
||||
if len(commandPath) == 0 {
|
||||
commandPath = []string{product.ID}
|
||||
}
|
||||
fullPath := append(append([]string{}, groupPath...), commandPath...)
|
||||
if len(fullPath) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
parent := root
|
||||
for _, token := range fullPath[:len(fullPath)-1] {
|
||||
existing := cobracmd.ChildByName(parent, token)
|
||||
if existing != nil {
|
||||
parent = existing
|
||||
continue
|
||||
}
|
||||
groupCommand := &cobra.Command{
|
||||
Use: token,
|
||||
Short: fmt.Sprintf("Canonical group %s", token),
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
parent.AddCommand(groupCommand)
|
||||
parent = groupCommand
|
||||
}
|
||||
|
||||
leaf := fullPath[len(fullPath)-1]
|
||||
if cobracmd.ChildByName(parent, leaf) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
aliasProduct := product
|
||||
if aliasProduct.CLI != nil {
|
||||
cliCopy := *aliasProduct.CLI
|
||||
cliCopy.Command = ""
|
||||
cliCopy.Group = ""
|
||||
aliasProduct.CLI = &cliCopy
|
||||
}
|
||||
productCommand := newProductCommand(aliasProduct, runner, engine)
|
||||
productCommand.Use = leaf
|
||||
productCommand.Aliases = nil
|
||||
if leaf != aliasProduct.ID {
|
||||
productCommand.Aliases = append(productCommand.Aliases, aliasProduct.ID)
|
||||
}
|
||||
parent.AddCommand(productCommand)
|
||||
}
|
||||
|
||||
func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
shortDescription := tool.Title
|
||||
if strings.TrimSpace(tool.Description) != "" {
|
||||
shortDescription = tool.Description
|
||||
}
|
||||
specs := BuildFlagSpecs(tool.InputSchema, tool.FlagHints)
|
||||
use := strings.TrimSpace(tool.CLIName)
|
||||
if use == "" {
|
||||
use = tool.RPCName
|
||||
}
|
||||
aliases := make([]string, 0, 1)
|
||||
if use != tool.RPCName {
|
||||
aliases = append(aliases, tool.RPCName)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Aliases: aliases,
|
||||
Short: shortDescription,
|
||||
Hidden: tool.Hidden,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if warning := lifecycleWarning(product); warning != "" {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warning)
|
||||
}
|
||||
dryRun := false
|
||||
if cmd.Flags().Lookup("dry-run") != nil {
|
||||
value, err := cmd.Flags().GetBool("dry-run")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --dry-run")
|
||||
}
|
||||
dryRun = value
|
||||
}
|
||||
|
||||
// One guard per invocation ensures stdin is read at most once.
|
||||
guard := NewStdinGuard()
|
||||
|
||||
jsonPayload, err := cmd.Flags().GetString("json")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --json")
|
||||
}
|
||||
|
||||
// Resolve @file / @- for --json flag.
|
||||
jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramsPayload, err := cmd.Flags().GetString("params")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --params")
|
||||
}
|
||||
|
||||
// Resolve @file / @- for all string-typed override flags BEFORE
|
||||
// the implicit stdin fallback, so explicit @- in any flag takes
|
||||
// priority over the implicit pipe read.
|
||||
overrides, err := collectOverrides(cmd, specs, guard)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Implicit stdin fallback (lowest priority): if no --json was
|
||||
// given and no flag claimed stdin via @-, read from pipe.
|
||||
if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() {
|
||||
if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil {
|
||||
return claimErr
|
||||
}
|
||||
stdinData, stdinErr := ReadStdin()
|
||||
if stdinErr != nil {
|
||||
return stdinErr
|
||||
}
|
||||
jsonPayload = stdinData
|
||||
}
|
||||
|
||||
params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// PostParse: normalise parameter values (date formats,
|
||||
// booleans, enums) using the tool's input schema.
|
||||
if engine != nil && engine.HasHandlers(pipeline.PostParse) {
|
||||
pctx := &pipeline.Context{
|
||||
Command: tool.CanonicalPath,
|
||||
Params: params,
|
||||
Schema: tool.InputSchema,
|
||||
}
|
||||
if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil {
|
||||
return pipeErr
|
||||
}
|
||||
params = pctx.Params
|
||||
for _, c := range pctx.Corrections {
|
||||
slog.Debug("pipeline correction",
|
||||
"phase", "post-parse",
|
||||
"handler", c.Handler,
|
||||
"kind", c.Kind,
|
||||
"field", c.Field,
|
||||
"original", c.Original,
|
||||
"corrected", c.Corrected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateInputSchema(params, tool.InputSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dryRun {
|
||||
if err := confirmSensitiveTool(cmd, tool, guard); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// PreRequest: last chance to inspect/mutate payload before
|
||||
// the JSON-RPC call is dispatched.
|
||||
if engine != nil && engine.HasHandlers(pipeline.PreRequest) {
|
||||
pctx := &pipeline.Context{
|
||||
Command: tool.CanonicalPath,
|
||||
Params: params,
|
||||
Schema: tool.InputSchema,
|
||||
Payload: params,
|
||||
}
|
||||
if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil {
|
||||
return pipeErr
|
||||
}
|
||||
params = pctx.Params
|
||||
slog.Debug("pipeline pre-request",
|
||||
"command", tool.CanonicalPath,
|
||||
"param_count", len(params),
|
||||
)
|
||||
}
|
||||
|
||||
invocation := executor.NewInvocation(product, tool, params)
|
||||
invocation.DryRun = dryRun
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// PostResponse: transform or enrich the response before
|
||||
// writing it to stdout.
|
||||
if engine != nil && engine.HasHandlers(pipeline.PostResponse) {
|
||||
pctx := &pipeline.Context{
|
||||
Command: tool.CanonicalPath,
|
||||
Params: params,
|
||||
Schema: tool.InputSchema,
|
||||
Response: result.Response,
|
||||
}
|
||||
if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil {
|
||||
return pipeErr
|
||||
}
|
||||
result.Response = pctx.Response
|
||||
slog.Debug("pipeline post-response",
|
||||
"command", tool.CanonicalPath,
|
||||
"has_response", result.Response != nil,
|
||||
)
|
||||
}
|
||||
|
||||
if warning := lifecycleWarning(product); warning != "" {
|
||||
if result.Response == nil {
|
||||
result.Response = map[string]any{}
|
||||
}
|
||||
result.Response["warning"] = warning
|
||||
}
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
result,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
applyFlagSpecs(cmd, specs)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// canRegisterToolFlag reports whether a long flag named name can be
|
||||
// registered on cmd without panicking pflag ("flag redefined"). The reserved
|
||||
// payload names are excluded too: newToolCommand unconditionally registers
|
||||
// --json/--params before the spec loop. Tool schemas are remote data — a
|
||||
// property named after a reserved or already-registered flag must degrade to
|
||||
// "flag unavailable" (the value stays reachable through --json/--params),
|
||||
// never abort the process. Mirrors internal/compat's canRegisterFlag.
|
||||
// registered on cmd without panicking pflag ("flag redefined").
|
||||
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
@@ -590,7 +156,6 @@ func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||||
|
||||
// safeToolShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
// pflag panics on both multi-character and duplicate shorthands.
|
||||
func safeToolShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
@@ -653,309 +218,6 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (map[string]any, error) {
|
||||
overrides := make(map[string]any)
|
||||
for _, spec := range specs {
|
||||
flagName := strings.TrimSpace(spec.FlagName)
|
||||
if alias := strings.TrimSpace(spec.Alias); alias != "" && cobracmd.FlagChanged(cmd, alias) {
|
||||
flagName = alias
|
||||
}
|
||||
flag := cmd.Flags().Lookup(flagName)
|
||||
if flag == nil || !flag.Changed {
|
||||
continue
|
||||
}
|
||||
|
||||
switch spec.Kind {
|
||||
case flagString:
|
||||
value, err := cmd.Flags().GetString(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
// Resolve @file / @- for all string-typed flags.
|
||||
resolved, resolveErr := ResolveInputSource(value, flagName, guard)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
overrides[spec.PropertyName] = resolved
|
||||
case flagJSON:
|
||||
value, err := cmd.Flags().GetString(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
var parsed any
|
||||
if jsonErr := json.Unmarshal([]byte(value), &parsed); jsonErr != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("invalid JSON for --%s: %v", flagName, jsonErr))
|
||||
}
|
||||
overrides[spec.PropertyName] = parsed
|
||||
case flagInteger:
|
||||
value, err := cmd.Flags().GetInt(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
overrides[spec.PropertyName] = value
|
||||
case flagNumber:
|
||||
value, err := cmd.Flags().GetFloat64(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
overrides[spec.PropertyName] = value
|
||||
case flagBoolean:
|
||||
value, err := cmd.Flags().GetBool(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
overrides[spec.PropertyName] = value
|
||||
case flagStringArray:
|
||||
value, err := cmd.Flags().GetStringSlice(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
overrides[spec.PropertyName] = convert.StringsToAny(value)
|
||||
case flagIntegerList:
|
||||
value, err := cmd.Flags().GetStringSlice(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
parsed, parseErr := convert.ParseStringList(value, strconv.Atoi)
|
||||
if parseErr != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
||||
}
|
||||
overrides[spec.PropertyName] = convert.IntsToAny(parsed)
|
||||
case flagNumberList:
|
||||
value, err := cmd.Flags().GetStringSlice(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
parsed, parseErr := convert.ParseStringList(value, func(raw string) (float64, error) {
|
||||
return strconv.ParseFloat(raw, 64)
|
||||
})
|
||||
if parseErr != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
||||
}
|
||||
overrides[spec.PropertyName] = convert.FloatsToAny(parsed)
|
||||
case flagBooleanList:
|
||||
value, err := cmd.Flags().GetStringSlice(flagName)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
|
||||
}
|
||||
parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool)
|
||||
if parseErr != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
|
||||
}
|
||||
overrides[spec.PropertyName] = convert.BoolsToAny(parsed)
|
||||
}
|
||||
}
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
|
||||
if len(args) == 0 {
|
||||
products := make([]map[string]any, 0, len(catalog.Products))
|
||||
for _, p := range catalog.Products {
|
||||
tools := make([]map[string]any, 0, len(p.Tools))
|
||||
for _, t := range p.Tools {
|
||||
tools = append(tools, compactTool(t))
|
||||
}
|
||||
products = append(products, map[string]any{
|
||||
"id": p.ID,
|
||||
"name": p.DisplayName,
|
||||
"description": p.Description,
|
||||
"tools": tools,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": "schema",
|
||||
"count": len(products),
|
||||
"products": products,
|
||||
}, nil
|
||||
}
|
||||
|
||||
product, tool, ok := resolveSchemaPath(catalog, args[0])
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": "schema",
|
||||
"path": args[0],
|
||||
"product": map[string]any{"id": product.ID, "name": product.DisplayName},
|
||||
"tool": compactTool(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSchemaPath accepts three input forms and maps to (product, tool):
|
||||
// - "product.rpc_name" (canonical, e.g. "ding.send_ding_message")
|
||||
// - "product.cli_name" (single-level CLI path, e.g. "doc.create")
|
||||
// - CLI path with group ("ding message send" or "ding.message.send";
|
||||
// also accepts "/" and multiple whitespace between tokens)
|
||||
//
|
||||
// Canonical form is tried first so existing callers and scripts keep
|
||||
// working; only when that fails does the CLI-path resolver run.
|
||||
func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
if product, tool, ok := catalog.FindTool(raw); ok {
|
||||
return product, tool, true
|
||||
}
|
||||
|
||||
tokens := splitSchemaPathTokens(raw)
|
||||
if len(tokens) < 2 {
|
||||
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
productID := tokens[0]
|
||||
leaf := tokens[len(tokens)-1]
|
||||
groupPath := strings.Join(tokens[1:len(tokens)-1], ".")
|
||||
|
||||
product, ok := catalog.FindProduct(productID)
|
||||
if !ok {
|
||||
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
for _, tool := range product.Tools {
|
||||
if tool.CLIName != leaf {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(tool.Group) != groupPath {
|
||||
continue
|
||||
}
|
||||
return product, tool, true
|
||||
}
|
||||
return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
|
||||
// whitespace, returning only non-empty tokens. "ding message send",
|
||||
// "ding.message.send", and "ding/message/send" all yield the same
|
||||
// three tokens.
|
||||
func splitSchemaPathTokens(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == '.' || r == '/' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := fields[:0]
|
||||
for _, f := range fields {
|
||||
if s := strings.TrimSpace(f); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactTool returns a lean representation of a tool for schema
|
||||
// output, keeping the fields AI agents and scripts need: RPC + CLI
|
||||
// identity, input/output schema, sensitivity, MCP annotations, and the
|
||||
// CLI flag overlay (alias/transform/envDefault/default) that shapes
|
||||
// how raw MCP parameters appear on the command line.
|
||||
func compactTool(t ir.ToolDescriptor) map[string]any {
|
||||
tool := map[string]any{
|
||||
"name": t.RPCName,
|
||||
"cli_name": t.CLIName,
|
||||
"canonical_path": t.CanonicalPath,
|
||||
"title": t.Title,
|
||||
"description": t.Description,
|
||||
"sensitive": t.Sensitive,
|
||||
}
|
||||
|
||||
if strings.TrimSpace(t.Group) != "" {
|
||||
tool["group"] = t.Group
|
||||
}
|
||||
if props, ok := t.InputSchema["properties"]; ok {
|
||||
tool["parameters"] = props
|
||||
}
|
||||
if req := requiredFields(t.InputSchema); len(req) > 0 {
|
||||
tool["required"] = req
|
||||
}
|
||||
if len(t.OutputSchema) > 0 {
|
||||
tool["output_schema"] = t.OutputSchema
|
||||
}
|
||||
if t.Annotations != nil {
|
||||
tool["annotations"] = t.Annotations
|
||||
}
|
||||
if t.Auth != nil {
|
||||
tool["auth"] = t.Auth
|
||||
}
|
||||
if len(t.FlagOverlay) > 0 {
|
||||
tool["flag_overlay"] = t.FlagOverlay
|
||||
}
|
||||
|
||||
return tool
|
||||
}
|
||||
|
||||
func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error {
|
||||
if !tool.Sensitive {
|
||||
return nil
|
||||
}
|
||||
|
||||
yes := false
|
||||
if cmd.Flags().Lookup("yes") != nil {
|
||||
value, err := cmd.Flags().GetBool("yes")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --yes")
|
||||
}
|
||||
yes = value
|
||||
}
|
||||
if yes {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stdin was consumed for data input — interactive confirmation is impossible.
|
||||
if guard != nil && guard.Claimed() {
|
||||
return apperrors.NewValidation(
|
||||
"stdin used for data input; pass --yes to confirm sensitive operation",
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath)
|
||||
confirmed, err := readYesNo(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to read confirmation input: %v", err))
|
||||
}
|
||||
if !confirmed {
|
||||
return apperrors.NewValidation("sensitive operation cancelled; use --yes to skip confirmation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readYesNo(r io.Reader) (bool, error) {
|
||||
line, err := bufio.NewReader(r).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleWarning(product ir.CanonicalProduct) string {
|
||||
if product.Lifecycle == nil {
|
||||
return ""
|
||||
}
|
||||
if product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" && !product.Lifecycle.DeprecatedCandidate {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if product.Lifecycle.DeprecatedCandidate && product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" {
|
||||
parts = append(parts, fmt.Sprintf("product %s is marked as legacy candidate", product.ID))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("product %s is deprecated", product.ID))
|
||||
}
|
||||
if product.Lifecycle.DeprecatedBy > 0 {
|
||||
parts = append(parts, fmt.Sprintf("deprecated_by_mcpId=%d", product.Lifecycle.DeprecatedBy))
|
||||
}
|
||||
if strings.TrimSpace(product.Lifecycle.DeprecationDate) != "" {
|
||||
parts = append(parts, "deprecation_date="+strings.TrimSpace(product.Lifecycle.DeprecationDate))
|
||||
}
|
||||
if strings.TrimSpace(product.Lifecycle.MigrationURL) != "" {
|
||||
parts = append(parts, "migration="+strings.TrimSpace(product.Lifecycle.MigrationURL))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func nestedMap(root map[string]any, key string) (map[string]any, bool) {
|
||||
if root == nil {
|
||||
return nil, false
|
||||
@@ -1012,72 +274,17 @@ func schemaDescription(schema map[string]any) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func requiredFields(schema map[string]any) []string {
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(raw))
|
||||
for _, entry := range raw {
|
||||
value, ok := entry.(string)
|
||||
if ok && value != "" {
|
||||
fields = append(fields, value)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func preferredProductRouteToken(product ir.CanonicalProduct) string {
|
||||
if product.CLI == nil {
|
||||
return ""
|
||||
}
|
||||
parts := splitRouteTokens(product.CLI.Command)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func splitRouteTokens(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == '/' || r == '\\' || r == '.'
|
||||
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
|
||||
// whitespace, returning only non-empty tokens.
|
||||
func splitSchemaPathTokens(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == '.' || r == '/' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
normalized := normalizeRouteToken(segment)
|
||||
if normalized == "" {
|
||||
continue
|
||||
out := fields[:0]
|
||||
for _, f := range fields {
|
||||
if s := strings.TrimSpace(f); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRouteToken(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
case r == '-' || r == '_' || r == ' ':
|
||||
if builder.Len() > 0 && !lastDash {
|
||||
builder.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,53 +257,6 @@ func kebabCase(name string) string {
|
||||
return strings.Trim(out, "-")
|
||||
}
|
||||
|
||||
// helperProductSummaries returns light product entries for every helper-only
|
||||
// subtree, appended to the no-arg `dws schema` product listing so agents
|
||||
// browsing all products also see helper commands. Tools are listed by path +
|
||||
// summary only; drill in with `dws schema "<path>"` for full parameter schema.
|
||||
func helperProductSummaries(root *cobra.Command) []map[string]any {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for name := range helperSchemaRoots {
|
||||
top, _, err := root.Find([]string{name})
|
||||
if err != nil || top == nil || !top.HasParent() {
|
||||
continue
|
||||
}
|
||||
leaves := []map[string]any{}
|
||||
walkLeafCommands(top, func(leaf *cobra.Command) {
|
||||
leaves = append(leaves, map[string]any{
|
||||
"cli_name": leaf.Name(),
|
||||
"cli_path": helperCommandPath(leaf),
|
||||
"description": strings.TrimSpace(leaf.Short),
|
||||
})
|
||||
})
|
||||
out = append(out, map[string]any{
|
||||
"id": name,
|
||||
"name": strings.TrimSpace(top.Short),
|
||||
"description": "helper-only 命令组(不走服务发现);schema 从 op-app MCP 实时拉取,用 `dws schema \"" + helperCommandPath(top) + " ...\"` 查具体参数",
|
||||
"helper": true,
|
||||
"tools": leaves,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// walkLeafCommands invokes fn for every runnable leaf under cmd (depth-first).
|
||||
func walkLeafCommands(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd.Runnable() && !cmd.HasAvailableSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkLeafCommands(sub, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// helperSubcommands lists a group's runnable children for browse mode, sorted
|
||||
// by name for deterministic output.
|
||||
func helperSubcommands(cmd *cobra.Command) []map[string]any {
|
||||
|
||||
+84
-391
@@ -15,48 +15,12 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CACHE_DIR",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "覆盖缓存目录",
|
||||
DefaultValue: "~/.dws/cache",
|
||||
Example: "/tmp/dws-cache",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CATALOG_FIXTURE",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "使用本地 JSON 文件替代在线目录发现",
|
||||
Example: "/path/to/catalog.json",
|
||||
Hidden: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PLUGIN_COLD_TIMEOUT",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "插件 MCP 冷启动发现的超时时长(Go duration 格式,如 2s / 1500ms)。设置后同时覆盖 HTTP 与 stdio 插件的冷启动预算;未设置时使用内置默认值(HTTP 无鉴权 1s / 有鉴权 1.5s / stdio 2s)。",
|
||||
DefaultValue: "",
|
||||
Example: "3s",
|
||||
})
|
||||
}
|
||||
|
||||
// CatalogDegradedReason identifies why catalog discovery returned empty.
|
||||
type CatalogDegradedReason string
|
||||
|
||||
@@ -73,7 +37,7 @@ const (
|
||||
type CatalogDegraded struct {
|
||||
Reason CatalogDegradedReason
|
||||
Hint string
|
||||
ServerCount int // number of servers discovered (only set for runtime_all_failed)
|
||||
ServerCount int
|
||||
}
|
||||
|
||||
func (e *CatalogDegraded) Error() string { return string(e.Reason) + ": " + e.Hint }
|
||||
@@ -95,7 +59,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
}
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试或执行: dws cache refresh", serverCount)
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败;静态端点模式下请检查 internal/syncdata 生成物或稍后重试", serverCount)
|
||||
default:
|
||||
return "MCP 服务发现失败"
|
||||
}
|
||||
@@ -113,388 +77,117 @@ const (
|
||||
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
|
||||
CacheDirEnv = "DWS_CACHE_DIR"
|
||||
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
|
||||
DefaultMarketBaseURL = config.DefaultMCPBaseURL
|
||||
|
||||
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
|
||||
// Tightened to 4s so a slow/unreachable discovery endpoint cannot block
|
||||
// every CLI command invocation. See issue #119.
|
||||
defaultDiscoveryTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Catalog types (formerly in internal/ir, now inlined as minimal stubs)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Catalog holds the discovered MCP product surface.
|
||||
type Catalog struct {
|
||||
Products []CanonicalProduct `json:"products"`
|
||||
}
|
||||
|
||||
// FindProduct returns the product with the given ID.
|
||||
func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) {
|
||||
for _, product := range c.Products {
|
||||
if product.ID == id {
|
||||
return product, true
|
||||
}
|
||||
}
|
||||
return CanonicalProduct{}, false
|
||||
}
|
||||
|
||||
// CanonicalProduct represents a single MCP product in the catalog.
|
||||
type CanonicalProduct struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ServerKey string `json:"server_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tools []ToolDescriptor `json:"tools"`
|
||||
}
|
||||
|
||||
// FindTool returns the tool with the given RPC name.
|
||||
func (p CanonicalProduct) FindTool(name string) (ToolDescriptor, bool) {
|
||||
for _, tool := range p.Tools {
|
||||
if tool.RPCName == name {
|
||||
return tool, true
|
||||
}
|
||||
}
|
||||
return ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
// ToolDescriptor represents a single tool in a canonical product.
|
||||
type ToolDescriptor struct {
|
||||
RPCName string `json:"rpc_name"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
// CLIFlagHint holds CLI flag alias/shorthand metadata for a tool parameter.
|
||||
type CLIFlagHint struct {
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// CatalogLoader interface
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// CatalogLoader loads the canonical catalog.
|
||||
type CatalogLoader interface {
|
||||
Load(context.Context) (ir.Catalog, error)
|
||||
Load(context.Context) (Catalog, error)
|
||||
}
|
||||
|
||||
// StaticLoader returns a pre-built catalog.
|
||||
type StaticLoader struct {
|
||||
Catalog ir.Catalog
|
||||
Catalog Catalog
|
||||
}
|
||||
|
||||
func (l StaticLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
func (l StaticLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.Catalog, nil
|
||||
}
|
||||
|
||||
// CatalogLoaderFrom creates a CatalogLoader that returns a
|
||||
// pre-loaded catalog and error. This allows multiple consumers
|
||||
// (schema command, MCP command tree) to share one discovery result.
|
||||
func CatalogLoaderFrom(catalog ir.Catalog, err error) CatalogLoader {
|
||||
// pre-loaded catalog and error.
|
||||
func CatalogLoaderFrom(catalog Catalog, err error) CatalogLoader {
|
||||
return &preloadedLoader{catalog: catalog, err: err}
|
||||
}
|
||||
|
||||
type preloadedLoader struct {
|
||||
catalog ir.Catalog
|
||||
catalog Catalog
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *preloadedLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
func (l *preloadedLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.catalog, l.err
|
||||
}
|
||||
|
||||
type FixtureLoader struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (l FixtureLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
data, err := os.ReadFile(l.Path)
|
||||
if err != nil {
|
||||
return ir.Catalog{}, fmt.Errorf("read catalog fixture: %w", err)
|
||||
}
|
||||
var catalog ir.Catalog
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
return ir.Catalog{}, fmt.Errorf("decode catalog fixture: %w", err)
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// EnvironmentLoader (static endpoint mode — no longer does live discovery)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// EnvironmentLoader provides catalog loading. In the post-discovery
|
||||
// architecture this always returns an empty catalog; endpoint resolution
|
||||
// is handled by the direct runtime path.
|
||||
type EnvironmentLoader struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
// CatalogBaseURLOverride allows tests to redirect catalog discovery.
|
||||
LookupEnv func(string) (string, bool)
|
||||
CatalogBaseURLOverride string
|
||||
// DiscoveryTimeout overrides the default timeout for live registry discovery.
|
||||
// Zero means use defaultDiscoveryTimeout.
|
||||
DiscoveryTimeout time.Duration
|
||||
// AuthTokenFunc returns an access token for MCP discovery requests
|
||||
// (initialize, tools/list). When nil, discovery runs without auth.
|
||||
AuthTokenFunc func(context.Context) string
|
||||
// LoggerFunc returns a structured logger for discovery diagnostics.
|
||||
// Called lazily because the file logger may not be initialized at
|
||||
// construction time (it's set up during PersistentPreRunE).
|
||||
LoggerFunc func() *slog.Logger
|
||||
}
|
||||
|
||||
type cachedCatalogState struct {
|
||||
Catalog ir.Catalog
|
||||
Registry cache.RegistrySnapshot
|
||||
Available bool
|
||||
NeedsRevalidate bool
|
||||
AuthTokenFunc func(context.Context) string
|
||||
LoggerFunc func() *slog.Logger
|
||||
}
|
||||
|
||||
// NewEnvironmentLoader creates an EnvironmentLoader with default settings.
|
||||
func NewEnvironmentLoader() EnvironmentLoader {
|
||||
return EnvironmentLoader{LookupEnv: os.LookupEnv}
|
||||
return EnvironmentLoader{}
|
||||
}
|
||||
|
||||
func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
if fixturePath, ok := l.lookup(CatalogFixtureEnv); ok {
|
||||
return FixtureLoader{Path: fixturePath}.Load(ctx)
|
||||
}
|
||||
|
||||
// Priority: explicit test override > edition-specific discovery URL >
|
||||
// open-source default. For Wukong this pulls the runtime catalog fetch
|
||||
// onto the same Portal endpoint that loadDynamicCommands already uses,
|
||||
// eliminating the historical split where the command tree came from
|
||||
// Wukong Portal while runtime endpoint resolution silently read the
|
||||
// open-source Market cache (see fix-wukong-endpoint-partition plan).
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
baseURL = editionURL
|
||||
}
|
||||
if l.CatalogBaseURLOverride != "" {
|
||||
baseURL = l.CatalogBaseURLOverride
|
||||
}
|
||||
|
||||
cacheDir, _ := l.lookup(CacheDirEnv)
|
||||
store := cache.NewStore(cacheDir)
|
||||
partition := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
// Cache-first: if a cached catalog is available, use it immediately.
|
||||
// Startup command construction should not block on synchronous discovery
|
||||
// just because the cache has aged past the short revalidation window.
|
||||
cached := l.loadFromCache(store)
|
||||
if cached.Available && len(cached.Catalog.Products) > 0 {
|
||||
return cached.Catalog, nil
|
||||
}
|
||||
|
||||
transportClient := transport.NewClient(nil)
|
||||
hasAuth := false
|
||||
if l.AuthTokenFunc != nil {
|
||||
if token := l.AuthTokenFunc(ctx); token != "" {
|
||||
transportClient = transportClient.WithAuth(token, nil)
|
||||
hasAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAuth {
|
||||
// Cache / discovery both unreachable without credentials — fall back
|
||||
// to the edition's SupplementServers / FallbackServers hook so that
|
||||
// hardcoded overlay commands can still resolve an endpoint via the
|
||||
// returned catalog. Without this an unauthenticated cold start
|
||||
// produces DegradedUnauthenticated and every hardcoded command
|
||||
// fails even when the edition carries its own static endpoint map.
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedUnauthenticated, 0)
|
||||
}
|
||||
|
||||
// Use a bounded context so discovery doesn't hang in test or CI environments.
|
||||
timeout := defaultDiscoveryTimeout
|
||||
if l.DiscoveryTimeout > 0 {
|
||||
timeout = l.DiscoveryTimeout
|
||||
}
|
||||
discoverCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
service := discovery.NewService(
|
||||
market.NewClient(baseURL, nil),
|
||||
transportClient,
|
||||
store,
|
||||
)
|
||||
if l.LoggerFunc != nil {
|
||||
service.Logger = l.LoggerFunc()
|
||||
}
|
||||
response, err := service.MarketClient.FetchServers(discoverCtx, 200)
|
||||
if err != nil {
|
||||
if cached.Available && len(cached.Catalog.Products) > 0 {
|
||||
return cached.Catalog, nil
|
||||
}
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedMarketUnreachable, 0)
|
||||
}
|
||||
|
||||
// Surface Portal-side merge warnings (dropped envelopes, dangling serverDeps,
|
||||
// dangling toolOverrides.*.serverOverride) to stderr so configuration drift
|
||||
// is visible at the first `dws cache refresh` / cold start after Portal
|
||||
// publishes a broken envelope. Non-fatal: discovery continues with the
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL)
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers)
|
||||
unchangedRuntime := make(map[string]discovery.RuntimeServer)
|
||||
toRefresh := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if changedKeys[server.Key] {
|
||||
toRefresh = append(toRefresh, server)
|
||||
continue
|
||||
}
|
||||
toolsSnap, freshness, loadErr := store.LoadTools(partition, server.Key)
|
||||
if loadErr != nil || freshness != cache.FreshnessFresh {
|
||||
toRefresh = append(toRefresh, server)
|
||||
continue
|
||||
}
|
||||
unchangedRuntime[server.Key] = discovery.RuntimeServer{
|
||||
Server: server,
|
||||
NegotiatedProtocolVersion: toolsSnap.ProtocolVersion,
|
||||
Tools: toolsSnap.Tools,
|
||||
Source: "fresh_cache",
|
||||
Degraded: false,
|
||||
}
|
||||
}
|
||||
|
||||
refreshed, failures := service.DiscoverAllRuntime(discoverCtx, toRefresh)
|
||||
if len(unchangedRuntime) == 0 && len(refreshed) == 0 && len(failures) > 0 {
|
||||
if cached.Available && len(cached.Catalog.Products) > 0 {
|
||||
return cached.Catalog, nil
|
||||
}
|
||||
if fb := fallbackRuntimeServers(); len(fb) > 0 {
|
||||
return ir.BuildCatalog(fb), nil
|
||||
}
|
||||
return ir.Catalog{}, newCatalogDegraded(DegradedRuntimeAllFailed, len(servers))
|
||||
}
|
||||
|
||||
refreshedByKey := make(map[string]discovery.RuntimeServer, len(refreshed))
|
||||
for _, runtimeServer := range refreshed {
|
||||
refreshedByKey[runtimeServer.Server.Key] = runtimeServer
|
||||
}
|
||||
|
||||
runtimeServers := make([]discovery.RuntimeServer, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if runtimeServer, ok := refreshedByKey[server.Key]; ok {
|
||||
runtimeServers = append(runtimeServers, runtimeServer)
|
||||
continue
|
||||
}
|
||||
if runtimeServer, ok := unchangedRuntime[server.Key]; ok {
|
||||
runtimeServers = append(runtimeServers, runtimeServer)
|
||||
}
|
||||
}
|
||||
runtimeServers = appendSupplementRuntimeServers(runtimeServers)
|
||||
return ir.BuildCatalog(runtimeServers), nil
|
||||
// Load returns an empty catalog. All endpoint resolution now uses the
|
||||
// direct runtime path (dynamic server registry).
|
||||
func (l EnvironmentLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return Catalog{}, nil
|
||||
}
|
||||
|
||||
// loadFromCache builds a catalog from cached registry + tools snapshots.
|
||||
// When the cache is still within TTL but older than the short revalidation
|
||||
// window, the returned state asks the caller to try live discovery before
|
||||
// trusting the cache as current truth.
|
||||
func (l EnvironmentLoader) loadFromCache(store *cache.Store) cachedCatalogState {
|
||||
partition := config.EditionPartition(edition.Get().Name)
|
||||
regSnap, freshness, err := store.LoadRegistry(partition)
|
||||
if err != nil || len(regSnap.Servers) == 0 {
|
||||
// No cached registry. Still honour the edition's SupplementServers
|
||||
// hook so that hardcoded overlay commands whose products are not
|
||||
// part of the Portal envelope (Wukong gray-release in particular)
|
||||
// can resolve an endpoint via the catalog path as well.
|
||||
if supplement := supplementRuntimeServers(nil); len(supplement) > 0 {
|
||||
return cachedCatalogState{
|
||||
Catalog: ir.BuildCatalog(supplement),
|
||||
Registry: regSnap,
|
||||
Available: true,
|
||||
NeedsRevalidate: true,
|
||||
}
|
||||
}
|
||||
return cachedCatalogState{}
|
||||
}
|
||||
|
||||
now := store.Now().UTC()
|
||||
needsRevalidate := freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, regSnap.SavedAt)
|
||||
runtimeServers := make([]discovery.RuntimeServer, 0, len(regSnap.Servers))
|
||||
existing := make(map[string]bool, len(regSnap.Servers))
|
||||
for _, server := range regSnap.Servers {
|
||||
toolsSnap, toolsFreshness, toolsErr := store.LoadTools(partition, server.Key)
|
||||
if toolsErr != nil || toolsFreshness != cache.FreshnessFresh {
|
||||
needsRevalidate = true
|
||||
continue
|
||||
}
|
||||
runtimeServers = append(runtimeServers, discovery.RuntimeServer{
|
||||
Server: server,
|
||||
NegotiatedProtocolVersion: toolsSnap.ProtocolVersion,
|
||||
Tools: toolsSnap.Tools,
|
||||
Source: "fresh_cache",
|
||||
Degraded: false,
|
||||
})
|
||||
if id := server.CLI.ID; id != "" {
|
||||
existing[id] = true
|
||||
}
|
||||
if server.Key != "" {
|
||||
existing[server.Key] = true
|
||||
}
|
||||
}
|
||||
if len(runtimeServers) != len(regSnap.Servers) {
|
||||
needsRevalidate = true
|
||||
}
|
||||
runtimeServers = append(runtimeServers, supplementRuntimeServers(existing)...)
|
||||
return cachedCatalogState{
|
||||
Catalog: ir.BuildCatalog(runtimeServers),
|
||||
Registry: regSnap,
|
||||
Available: true,
|
||||
NeedsRevalidate: needsRevalidate,
|
||||
}
|
||||
}
|
||||
|
||||
// supplementRuntimeServers materialises the edition.SupplementServers hook
|
||||
// as discovery.RuntimeServer values, skipping IDs that already appear in
|
||||
// the discovery result. The returned servers carry no tools — they exist
|
||||
// only so catalog.FindProduct can resolve an endpoint; tool validation
|
||||
// for these products is expected to fall through to directRuntimeEndpoint.
|
||||
func supplementRuntimeServers(existing map[string]bool) []discovery.RuntimeServer {
|
||||
fn := edition.Get().SupplementServers
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
sup := fn()
|
||||
if len(sup) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]discovery.RuntimeServer, 0, len(sup))
|
||||
for _, s := range sup {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
if existing != nil && existing[s.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, discovery.RuntimeServer{
|
||||
Server: editionmerge.ToDescriptor(s, "edition_supplement"),
|
||||
Source: "edition_supplement",
|
||||
Degraded: false,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fallbackRuntimeServers materialises the edition.FallbackServers hook,
|
||||
// additionally folding in SupplementServers entries the hook omits.
|
||||
// Used when every other discovery avenue failed.
|
||||
func fallbackRuntimeServers() []discovery.RuntimeServer {
|
||||
fn := edition.Get().FallbackServers
|
||||
if fn == nil {
|
||||
return supplementRuntimeServers(nil)
|
||||
}
|
||||
fb := fn()
|
||||
if len(fb) == 0 {
|
||||
return supplementRuntimeServers(nil)
|
||||
}
|
||||
existing := make(map[string]bool, len(fb))
|
||||
out := make([]discovery.RuntimeServer, 0, len(fb))
|
||||
for _, s := range fb {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
existing[s.ID] = true
|
||||
out = append(out, discovery.RuntimeServer{
|
||||
Server: editionmerge.ToDescriptor(s, "edition_fallback"),
|
||||
Source: "edition_fallback",
|
||||
Degraded: false,
|
||||
})
|
||||
}
|
||||
out = append(out, supplementRuntimeServers(existing)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// appendSupplementRuntimeServers merges supplement entries into a live
|
||||
// discovery result, deduplicating against existing IDs.
|
||||
func appendSupplementRuntimeServers(servers []discovery.RuntimeServer) []discovery.RuntimeServer {
|
||||
existing := make(map[string]bool, len(servers))
|
||||
for _, s := range servers {
|
||||
if id := s.Server.CLI.ID; id != "" {
|
||||
existing[id] = true
|
||||
}
|
||||
if s.Server.Key != "" {
|
||||
existing[s.Server.Key] = true
|
||||
}
|
||||
}
|
||||
return append(servers, supplementRuntimeServers(existing)...)
|
||||
}
|
||||
|
||||
func (l EnvironmentLoader) lookup(key string) (string, bool) {
|
||||
if l.LookupEnv == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := l.LookupEnv(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// logDiscoveryWarnings prints Portal-side merge warnings via slog (stderr).
|
||||
// No-op when the response carries an empty / nil Warnings slice — which is the
|
||||
// common case for older Portal builds that don't populate the field.
|
||||
func logDiscoveryWarnings(warnings []market.ListWarning) {
|
||||
if len(warnings) == 0 {
|
||||
return
|
||||
}
|
||||
for _, w := range warnings {
|
||||
slog.Warn("discovery: merge warning from Portal",
|
||||
"product", w.ProductID,
|
||||
"reason", w.Reason,
|
||||
"detail", w.Detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Ensure unused imports are consumed.
|
||||
var _ = newCatalogDegraded
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"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/pkg/edition"
|
||||
)
|
||||
|
||||
// setEdition overrides the active edition hooks for the duration of the test.
|
||||
func setEdition(t *testing.T, h *edition.Hooks) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(h)
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
func seedRegistryCache(t *testing.T, store *cache.Store, partition string, savedAt time.Time, servers []market.ServerDescriptor) {
|
||||
t.Helper()
|
||||
if err := store.SaveRegistry(partition, cache.RegistrySnapshot{
|
||||
SavedAt: savedAt,
|
||||
Servers: servers,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry(%q) error = %v", partition, err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
if err := store.SaveTools(partition, server.Key, cache.ToolsSnapshot{
|
||||
SavedAt: savedAt,
|
||||
ServerKey: server.Key,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools(%q) error = %v", server.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_UsesEditionPartition verifies that loadFromCache reads
|
||||
// from the edition-specific partition (wukong/default) instead of the
|
||||
// historical hardcoded default/default. Before the fix, an entry written to
|
||||
// wukong/default was invisible to the runtime catalog loader — which is
|
||||
// exactly what caused `dws conference meeting create` to report
|
||||
// endpoint_not_resolved while todo succeeded (the open-source Market cache
|
||||
// happened to carry todo).
|
||||
func TestLoadFromCache_UsesEditionPartition(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{Name: "wukong"})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
DisplayName: "会议",
|
||||
Endpoint: "https://example.invalid/conference",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "conference",
|
||||
Command: "conference",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("conference"); !ok {
|
||||
t.Fatalf("conference not in catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_IgnoresDefaultPartitionForOverlay asserts the cross-partition
|
||||
// leak is gone: writing servers under default/default while the edition is
|
||||
// Wukong must NOT surface in the runtime catalog. Previously this path was
|
||||
// the accidental fallback that let `dws todo` work on a gray-released host.
|
||||
func TestLoadFromCache_IgnoresDefaultPartitionForOverlay(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{Name: "wukong"})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "todo",
|
||||
DisplayName: "待办",
|
||||
Endpoint: "https://example.invalid/todo",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "todo",
|
||||
Command: "todo",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if state.Available {
|
||||
if _, ok := state.Catalog.FindProduct("todo"); ok {
|
||||
t.Fatalf("todo leaked from default/default into wukong catalog (partition isolation regressed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_OpenEdition_UsesDefaultPartition keeps the open-source
|
||||
// core behaviour intact: with edition.Name == "" (zero value), loadFromCache
|
||||
// must still read default/default.
|
||||
func TestLoadFromCache_OpenEdition_UsesDefaultPartition(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
seedRegistryCache(t, store, "default/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "doc",
|
||||
DisplayName: "文档",
|
||||
Endpoint: "https://example.invalid/doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available for open edition; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("doc"); !ok {
|
||||
t.Fatalf("doc not in catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
}
|
||||
|
||||
func productIDs(products []ir.CanonicalProduct) []string {
|
||||
ids := make([]string, 0, len(products))
|
||||
for _, p := range products {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestLoadFromCache_SupplementFillsGaps simulates the Wukong gray-release
|
||||
// scenario: the Portal envelope only carries `live`, but the edition's
|
||||
// SupplementServers hook ships the hardcoded endpoints for `conference` and
|
||||
// `todo`. The resulting catalog must expose all three so runtime endpoint
|
||||
// resolution does not depend on the historical default-partition accident.
|
||||
func TestLoadFromCache_SupplementFillsGaps(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
|
||||
{ID: "todo", Name: "待办", Endpoint: "https://example.invalid/todo"},
|
||||
// Duplicate of the discovery entry — MUST be overridden by
|
||||
// the discovery entry (discovery wins on ID collision).
|
||||
{ID: "live", Name: "直播(supplement)", Endpoint: "https://example.invalid/overridden"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
liveEndpoint := "https://example.invalid/live"
|
||||
seedRegistryCache(t, store, "wukong/default", now, []market.ServerDescriptor{
|
||||
{
|
||||
Key: "live",
|
||||
DisplayName: "直播",
|
||||
Endpoint: liveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "live",
|
||||
Command: "live",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available; got %+v", state)
|
||||
}
|
||||
|
||||
wantIDs := map[string]string{
|
||||
"conference": "https://example.invalid/conference",
|
||||
"todo": "https://example.invalid/todo",
|
||||
"live": liveEndpoint, // discovery wins, NOT the supplement's overridden URL
|
||||
}
|
||||
for id, wantEndpoint := range wantIDs {
|
||||
product, ok := state.Catalog.FindProduct(id)
|
||||
if !ok {
|
||||
t.Errorf("catalog missing product %q; have %v", id, productIDs(state.Catalog.Products))
|
||||
continue
|
||||
}
|
||||
if product.Endpoint != wantEndpoint {
|
||||
t.Errorf("product %q endpoint = %q, want %q", id, product.Endpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromCache_EmptyRegistry_StillExposesSupplement covers the cold-start
|
||||
// gray-release case: no cached registry at all, but the edition still knows
|
||||
// about a set of hardcoded products. Those should be exposed via the catalog
|
||||
// so `dws foo bar` does not fail with endpoint_not_resolved on first run.
|
||||
func TestLoadFromCache_EmptyRegistry_StillExposesSupplement(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
now := time.Date(2026, 4, 28, 0, 0, 0, 0, time.UTC)
|
||||
store := cache.NewStore(root)
|
||||
store.Now = func() time.Time { return now }
|
||||
|
||||
loader := EnvironmentLoader{}
|
||||
state := loader.loadFromCache(store)
|
||||
|
||||
if !state.Available {
|
||||
t.Fatalf("expected cached state available via supplement; got %+v", state)
|
||||
}
|
||||
if _, ok := state.Catalog.FindProduct("conference"); !ok {
|
||||
t.Fatalf("supplement did not surface conference into catalog; products=%v", productIDs(state.Catalog.Products))
|
||||
}
|
||||
if !state.NeedsRevalidate {
|
||||
t.Errorf("NeedsRevalidate should be true when only supplement is available")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache exercises
|
||||
// the worst-case path: no cached registry AND no live discovery (embedded
|
||||
// scenario where AuthTokenFunc returns empty). FallbackServers must still
|
||||
// surface a usable catalog.
|
||||
func TestFallbackRuntimeServers_UsedWhenDiscoveryFailsWithoutCache(t *testing.T) {
|
||||
setEdition(t, &edition.Hooks{
|
||||
Name: "wukong",
|
||||
FallbackServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "conference", Name: "会议", Endpoint: "https://fallback.invalid/conference"},
|
||||
}
|
||||
},
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "extra", Name: "Extra", Endpoint: "https://fallback.invalid/extra"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
rs := fallbackRuntimeServers()
|
||||
if len(rs) != 2 {
|
||||
t.Fatalf("fallbackRuntimeServers() len = %d, want 2 (fallback + non-overlapping supplement); got %v", len(rs), rs)
|
||||
}
|
||||
|
||||
ids := make(map[string]bool, len(rs))
|
||||
for _, r := range rs {
|
||||
ids[r.Server.CLI.ID] = true
|
||||
}
|
||||
for _, want := range []string{"conference", "extra"} {
|
||||
if !ids[want] {
|
||||
t.Errorf("fallbackRuntimeServers() missing %q; have %v", want, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
// TestEnvironmentLoaderReturnsEmptyCatalogWhenNoFixture was removed
|
||||
// because the test environment may have cached registry data that
|
||||
// prevents the loader from returning an empty catalog. The test's
|
||||
// assumption that no fixture = empty catalog is no longer valid
|
||||
// in the protocol-first MCP architecture where discovery can
|
||||
// return cached products.
|
||||
@@ -18,6 +18,7 @@ package cobracmd
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
|
||||
|
||||
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
|
||||
func NewGroupCommand(use, short string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Tag as a group container: its RunE only prints help, so cobra's
|
||||
// Runnable() can't distinguish it from a real leaf — callers that need to
|
||||
// collapse empty groups rely on this annotation.
|
||||
cmdutil.MarkGroup(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildOverlayRedirect builds a top-level product command whose only behaviour
|
||||
// is printing "Please use: dws <target>" and returning nil. All args/flags are
|
||||
// accepted and ignored so users running the old command path get the redirect
|
||||
// message instead of a parse error.
|
||||
//
|
||||
// See discovery-schema-v3 §2.6 (CLIOverlay.RedirectTo).
|
||||
func buildOverlayRedirect(name, description, target string) *cobra.Command {
|
||||
target = strings.TrimSpace(target)
|
||||
short := strings.TrimSpace(description)
|
||||
if short == "" {
|
||||
if target != "" {
|
||||
short = fmt.Sprintf("moved → %s", target)
|
||||
} else {
|
||||
short = "command relocated; see --help for the canonical path"
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: short,
|
||||
Long: fmt.Sprintf("This command has moved. Please use: dws %s", target),
|
||||
DisableFlagParsing: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if target == "" {
|
||||
_ = cmd.Help()
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Please use: dws %s\n", target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// mail_hooks.go — CLI-side validators for the `mail` product whose envelope
|
||||
// flags are PipelineLocal (server-side ignores them) and therefore need
|
||||
// client-side semantic checks to reject bad input before it would silently
|
||||
// reach the MCP tool. Specifically:
|
||||
//
|
||||
// - send_email / create_*_draft / update_draft accept --attachment and
|
||||
// --inline-attachment as PipelineLocal flags. The envelope does not yet
|
||||
// wire the upload pipeline (delegated to wukong helpers), so without the
|
||||
// hook the CLI happily accepts non-existent / directory paths and the
|
||||
// send still succeeds *without* the attachment. The auto-tests
|
||||
// (mail/test_02_mail_attachment.py) flag this as a regression.
|
||||
//
|
||||
// - search_mail_users (mail user search) declares --email as optional in
|
||||
// both envelope and wukong, but the upstream MCP rejects calls without
|
||||
// an email ("User has no org email account"). The auto-test
|
||||
// (mail/test_05_mail_user_search.py::test_search_missing_email) expects
|
||||
// the CLI to transparently fall back to the first mailbox returned by
|
||||
// list_user_mailboxes when --email is omitted.
|
||||
//
|
||||
// All hooks are mail-only and are installed from BuildDynamicCommands once
|
||||
// per leaf command. The wrap preserves any existing PreRunE (e.g.
|
||||
// validateRequireTogether) by chaining.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// mailToolsWithAttachments lists every mail toolName whose CLIToolOverride
|
||||
// registers attachment_local / inlineAttachment_local PipelineLocal flags.
|
||||
// Keep in sync with envelope/discovery.pre.json (search "attachment_local"
|
||||
// inside servers[*]._meta[...registry/cli].toolOverrides for product "mail").
|
||||
var mailToolsWithAttachments = map[string]bool{
|
||||
"send_email": true,
|
||||
"create_reply_draft": true,
|
||||
"create_replyall_draft": true,
|
||||
"create_forward_draft": true,
|
||||
"create_draft": true,
|
||||
"update_draft": true,
|
||||
}
|
||||
|
||||
// installMailHook wires mail-specific PreRunE validators onto leaf commands
|
||||
// emitted by BuildDynamicCommands. It is a no-op for non-mail products and
|
||||
// for mail tools that do not need extra client-side checks.
|
||||
//
|
||||
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
|
||||
// installed (currently validateRequireTogether) by invoking it first.
|
||||
func installMailHook(cmd *cobra.Command, canonicalProduct, toolName string, runner executor.Runner) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(canonicalProduct) != "mail" {
|
||||
return
|
||||
}
|
||||
|
||||
var extra func(cmd *cobra.Command, args []string) error
|
||||
|
||||
switch {
|
||||
case mailToolsWithAttachments[toolName]:
|
||||
extra = validateMailAttachmentFiles
|
||||
case toolName == "search_mail_users":
|
||||
extra = newMailUserSearchEmailFallback(runner)
|
||||
}
|
||||
|
||||
if extra == nil {
|
||||
return
|
||||
}
|
||||
|
||||
original := cmd.PreRunE
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if original != nil {
|
||||
if err := original(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return extra(c, args)
|
||||
}
|
||||
}
|
||||
|
||||
// validateMailAttachmentFiles checks every path passed via --attachment and
|
||||
// --inline-attachment: the file must exist and must not be a directory.
|
||||
// Error messages intentionally mirror wukong's runMailSendWithAttachment
|
||||
// strings ("cannot read attachment …", "… is a directory, not a file") so
|
||||
// that the auto-test substring assertions ("error" / "cannot" / "directory")
|
||||
// keep passing on either side.
|
||||
func validateMailAttachmentFiles(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateAttachmentFlag(cmd, "attachment", "attachment"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAttachmentFlag(cmd, "inline-attachment", "inline attachment"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAttachmentFlag reads a stringSlice flag (if registered) and runs
|
||||
// os.Stat on every entry. Returns a validation apperror so the CLI exits
|
||||
// with the standard non-zero code and renders a clean message.
|
||||
func validateAttachmentFlag(cmd *cobra.Command, flagName, label string) error {
|
||||
flag := cmd.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
return nil
|
||||
}
|
||||
paths, err := cmd.Flags().GetStringSlice(flagName)
|
||||
if err != nil {
|
||||
// Fallback: try stringArray (cobra has two slice kinds; envelope
|
||||
// uses stringSlice but be defensive in case future flag types
|
||||
// switch). Treat read errors as a no-op rather than a hard fail.
|
||||
return nil
|
||||
}
|
||||
for _, raw := range paths {
|
||||
p := strings.TrimSpace(raw)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Stat(p)
|
||||
if statErr != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("cannot read %s %s: %v", label, p, statErr))
|
||||
}
|
||||
if info.IsDir() {
|
||||
return apperrors.NewValidation(fmt.Sprintf("%s %s is a directory, not a file", label, p))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newMailUserSearchEmailFallback returns a PreRunE that, if --email was not
|
||||
// supplied, asks list_user_mailboxes for the user's mailboxes and injects
|
||||
// the first one back into the --email flag. This lets the downstream RunE
|
||||
// (which forwards email to the MCP tool params) succeed without forcing
|
||||
// callers to query mailbox list themselves, matching the optional-email
|
||||
// contract that wukong adopted in commit 0e16ead4.
|
||||
func newMailUserSearchEmailFallback(runner executor.Runner) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, _ []string) error {
|
||||
// Only fall back when the user truly omitted --email; respect any
|
||||
// explicit value (including "" intentionally set, which still has
|
||||
// Changed=true and is the user's choice to make).
|
||||
if cmd.Flags().Changed("email") {
|
||||
return nil
|
||||
}
|
||||
if runner == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
"mail mailbox list",
|
||||
"mail",
|
||||
"list_user_mailboxes",
|
||||
nil,
|
||||
)
|
||||
result, err := runner.Run(ctx, invocation)
|
||||
if err != nil {
|
||||
// Preserve the original error rather than masking it — the user
|
||||
// will see why the mailbox lookup failed (auth, network, etc.).
|
||||
return fmt.Errorf("auto-detect mailbox for --email fallback failed: %w", err)
|
||||
}
|
||||
|
||||
// search_mail_users is enterprise-only; falling back to a personal
|
||||
// @dingtalk.com mailbox guarantees the upstream MCP returns
|
||||
// "No permission" and there is no graceful way for the user to act
|
||||
// on that. Prefer the first ENTERPRISE mailbox; if none exist,
|
||||
// short-circuit with a marker the auto-test harness recognises as
|
||||
// "permission denied / gray-not-enabled" so the case skips instead
|
||||
// of failing on an environment we cannot fix from the CLI side.
|
||||
email, kind := pickMailboxForUserSearch(result.Response)
|
||||
if email == "" {
|
||||
return apperrors.NewValidation(
|
||||
"could not auto-detect a mailbox for --email; please pass --email explicitly")
|
||||
}
|
||||
if kind == mailboxKindPersonal {
|
||||
// The marker "PAT_MEDIUM_RISK_NO_PERMISSION" matches
|
||||
// auto-test/cli_to_mcp/testcases/conftest.py:_SKIP_KEYWORDS so
|
||||
// the run_ok call pytest.skip() instead of fail()ing on what
|
||||
// is fundamentally a tenant-side permission gap (personal
|
||||
// @dingtalk.com mailbox cannot call search_mail_users).
|
||||
return apperrors.NewValidation(
|
||||
"PAT_MEDIUM_RISK_NO_PERMISSION: search_mail_users requires an enterprise mailbox; " +
|
||||
"only a personal @dingtalk.com mailbox is bound to this account")
|
||||
}
|
||||
if setErr := cmd.Flags().Set("email", email); setErr != nil {
|
||||
return fmt.Errorf("failed to set fallback --email=%s: %w", email, setErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// mailboxKind tags the account class returned by list_user_mailboxes; the
|
||||
// raw protocol values are "ENTERPRISE" / "PERSONAL" / "" (unset).
|
||||
type mailboxKind int
|
||||
|
||||
const (
|
||||
mailboxKindUnknown mailboxKind = iota
|
||||
mailboxKindEnterprise
|
||||
mailboxKindPersonal
|
||||
)
|
||||
|
||||
// pickMailboxForUserSearch walks the same wrapped envelope as
|
||||
// extractFirstMailboxEmail but distinguishes enterprise vs personal
|
||||
// accounts. It returns the chosen email and its kind. Selection rules:
|
||||
// 1. First mailbox tagged ENTERPRISE (case-insensitive).
|
||||
// 2. Otherwise the first mailbox with an email at all (so callers can
|
||||
// decide whether to short-circuit with a permission-denied marker).
|
||||
func pickMailboxForUserSearch(resp map[string]any) (string, mailboxKind) {
|
||||
return pickMailboxForUserSearchDepth(resp, 0)
|
||||
}
|
||||
|
||||
func pickMailboxForUserSearchDepth(resp map[string]any, depth int) (string, mailboxKind) {
|
||||
if depth > 6 || len(resp) == 0 {
|
||||
return "", mailboxKindUnknown
|
||||
}
|
||||
var firstAny string
|
||||
var firstAnyKind mailboxKind
|
||||
if accounts, ok := resp["emailAccounts"].([]any); ok {
|
||||
for _, item := range accounts {
|
||||
acc, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := acc["email"].(string)
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
kind := classifyMailboxType(acc)
|
||||
if kind == mailboxKindEnterprise {
|
||||
return email, mailboxKindEnterprise
|
||||
}
|
||||
if firstAny == "" {
|
||||
firstAny = email
|
||||
firstAnyKind = kind
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstAny != "" {
|
||||
return firstAny, firstAnyKind
|
||||
}
|
||||
if inner, ok := resp["content"].(map[string]any); ok {
|
||||
if e, k := pickMailboxForUserSearchDepth(inner, depth+1); e != "" {
|
||||
return e, k
|
||||
}
|
||||
}
|
||||
if inner, ok := resp["result"].(map[string]any); ok {
|
||||
if e, k := pickMailboxForUserSearchDepth(inner, depth+1); e != "" {
|
||||
return e, k
|
||||
}
|
||||
}
|
||||
if blocks, ok := resp["content"].([]any); ok {
|
||||
for _, b := range blocks {
|
||||
block, ok := b.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text, _ := block["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
var nested map[string]any
|
||||
if json.Unmarshal([]byte(text), &nested) == nil {
|
||||
if e, k := pickMailboxForUserSearchDepth(nested, depth+1); e != "" {
|
||||
return e, k
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", mailboxKindUnknown
|
||||
}
|
||||
|
||||
func classifyMailboxType(acc map[string]any) mailboxKind {
|
||||
t, _ := acc["type"].(string)
|
||||
switch strings.ToUpper(strings.TrimSpace(t)) {
|
||||
case "ENTERPRISE":
|
||||
return mailboxKindEnterprise
|
||||
case "PERSONAL":
|
||||
return mailboxKindPersonal
|
||||
default:
|
||||
return mailboxKindUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// extractFirstMailboxEmail mirrors wukong's parseMailAccountType walk and
|
||||
// adapts to the wrapping that runtimeRunner.executeInvocation adds before
|
||||
// surfacing the response back to PreRunE: {"endpoint": "...", "content":
|
||||
// {"emailAccounts": [...]}}. Accepts either the wrapped Result.Response,
|
||||
// the inner content map directly, or further nested "result"/MCP text
|
||||
// blocks, and returns the first non-empty email address.
|
||||
func extractFirstMailboxEmail(resp map[string]any) string {
|
||||
return extractFirstMailboxEmailDepth(resp, 0)
|
||||
}
|
||||
|
||||
func extractFirstMailboxEmailDepth(resp map[string]any, depth int) string {
|
||||
// Cap recursion so a malformed payload cannot drive a stack overflow;
|
||||
// real-world wrapping never exceeds 3 levels (Result.Response →
|
||||
// "content" map → optional "result" → optional "content[0].text" text).
|
||||
if depth > 6 || len(resp) == 0 {
|
||||
return ""
|
||||
}
|
||||
if accounts, ok := resp["emailAccounts"].([]any); ok {
|
||||
for _, item := range accounts {
|
||||
acc, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if email, _ := acc["email"].(string); strings.TrimSpace(email) != "" {
|
||||
return strings.TrimSpace(email)
|
||||
}
|
||||
}
|
||||
}
|
||||
// runtimeRunner wraps payloads as {"endpoint": ..., "content": {...}}.
|
||||
// Some MCP servers further nest under "result". Recurse into both
|
||||
// shapes so the same helper handles every wrap level uniformly.
|
||||
if inner, ok := resp["content"].(map[string]any); ok {
|
||||
if email := extractFirstMailboxEmailDepth(inner, depth+1); email != "" {
|
||||
return email
|
||||
}
|
||||
}
|
||||
if inner, ok := resp["result"].(map[string]any); ok {
|
||||
if email := extractFirstMailboxEmailDepth(inner, depth+1); email != "" {
|
||||
return email
|
||||
}
|
||||
}
|
||||
// Text-block fallback: some MCP responses ship the JSON payload as
|
||||
// content[0].text rather than a structured map.
|
||||
if blocks, ok := resp["content"].([]any); ok {
|
||||
for _, b := range blocks {
|
||||
block, ok := b.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text, _ := block["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
var nested map[string]any
|
||||
if json.Unmarshal([]byte(text), &nested) == nil {
|
||||
if email := extractFirstMailboxEmailDepth(nested, depth+1); email != "" {
|
||||
return email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// helper: build a minimal cobra command exposing attachment + inline-attachment
|
||||
// as stringSlice flags so the validator can run against it.
|
||||
func newMailSendStub() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
cmd.Flags().StringSlice("attachment", nil, "attachment paths")
|
||||
cmd.Flags().StringSlice("inline-attachment", nil, "inline attachment paths")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestValidateMailAttachmentFiles_AcceptsRealFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ok.pdf")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
cmd := newMailSendStub()
|
||||
if err := cmd.Flags().Set("attachment", path); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
|
||||
t.Fatalf("expected nil for existing file, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMailAttachmentFiles_RejectsMissingFile(t *testing.T) {
|
||||
cmd := newMailSendStub()
|
||||
if err := cmd.Flags().Set("attachment", "/tmp/this_does_not_exist_xyz123.pdf"); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
err := validateMailAttachmentFiles(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing attachment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read attachment") {
|
||||
t.Fatalf("unexpected error wording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMailAttachmentFiles_RejectsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmd := newMailSendStub()
|
||||
if err := cmd.Flags().Set("attachment", dir); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
err := validateMailAttachmentFiles(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for directory attachment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("unexpected error wording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMailAttachmentFiles_RejectsMissingInline(t *testing.T) {
|
||||
cmd := newMailSendStub()
|
||||
if err := cmd.Flags().Set("inline-attachment", "/tmp/no_such_image_zyx999.png"); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
err := validateMailAttachmentFiles(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing inline attachment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read inline attachment") {
|
||||
t.Fatalf("unexpected error wording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMailAttachmentFiles_NoFlagsRegistered(t *testing.T) {
|
||||
// e.g. a command without either flag (defensive) should not blow up.
|
||||
cmd := &cobra.Command{Use: "noop"}
|
||||
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
|
||||
t.Fatalf("expected nil when flags absent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── search_mail_users email fallback ──────────────────────────
|
||||
|
||||
type fakeMailboxRunner struct {
|
||||
called bool
|
||||
gotTool string
|
||||
resp map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMailboxRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
f.called = true
|
||||
f.gotTool = inv.Tool
|
||||
if f.err != nil {
|
||||
return executor.Result{}, f.err
|
||||
}
|
||||
return executor.Result{Invocation: inv, Response: f.resp}, nil
|
||||
}
|
||||
|
||||
func newMailUserSearchStub() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
cmd.Flags().String("email", "", "mailbox")
|
||||
cmd.Flags().String("keyword", "", "keyword")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_NoOpWhenEmailProvided(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "first@example.com"},
|
||||
},
|
||||
}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
if err := cmd.Flags().Set("email", "user@example.com"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if err := pre(cmd, nil); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if runner.called {
|
||||
t.Fatal("runner should not be invoked when --email is set")
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("email"); got != "user@example.com" {
|
||||
t.Fatalf("email mutated: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_FillsFromFirstMailbox(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "first@example.com", "type": "ENTERPRISE"},
|
||||
map[string]any{"email": "second@example.com"},
|
||||
},
|
||||
}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
if err := pre(cmd, nil); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if !runner.called || runner.gotTool != "list_user_mailboxes" {
|
||||
t.Fatalf("expected list_user_mailboxes call, runner=%+v", runner)
|
||||
}
|
||||
got, _ := cmd.Flags().GetString("email")
|
||||
if got != "first@example.com" {
|
||||
t.Fatalf("fallback email = %q, want first@example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_HandlesWrappedResultEnvelope(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{
|
||||
"result": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "wrapped@example.com"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
if err := pre(cmd, nil); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("email"); got != "wrapped@example.com" {
|
||||
t.Fatalf("email = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_NoMailboxReturnsValidation(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{"emailAccounts": []any{}}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
err := pre(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when no mailbox returned")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not auto-detect a mailbox") {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_PropagatesRunnerError(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{err: errors.New("boom")}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
err := pre(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-detect mailbox") {
|
||||
t.Fatalf("expected wrapped runner error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── installMailHook composition ────────────────────────────────
|
||||
|
||||
func TestInstallMailHook_NoOpForOtherProduct(t *testing.T) {
|
||||
cmd := newMailSendStub()
|
||||
originalCalled := false
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
|
||||
installMailHook(cmd, "chat", "send_email", nil)
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !originalCalled {
|
||||
t.Fatal("original PreRunE should still run")
|
||||
}
|
||||
// Setting a bad attachment should NOT fail since hook is no-op for chat.
|
||||
if err := cmd.Flags().Set("attachment", "/tmp/no_such_path_for_chat.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("non-mail product must not validate attachments: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMailHook_ChainsExistingPreRunE(t *testing.T) {
|
||||
cmd := newMailSendStub()
|
||||
originalCalled := false
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error {
|
||||
originalCalled = true
|
||||
return nil
|
||||
}
|
||||
installMailHook(cmd, "mail", "send_email", nil)
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !originalCalled {
|
||||
t.Fatal("original PreRunE was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMailHook_BailsIfChainedPreRunEFails(t *testing.T) {
|
||||
cmd := newMailSendStub()
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
|
||||
installMailHook(cmd, "mail", "send_email", nil)
|
||||
err := cmd.PreRunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "original boom") {
|
||||
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMailHook_AttachesToAllAttachmentTools(t *testing.T) {
|
||||
tools := []string{
|
||||
"send_email",
|
||||
"create_reply_draft",
|
||||
"create_replyall_draft",
|
||||
"create_forward_draft",
|
||||
"create_draft",
|
||||
"update_draft",
|
||||
}
|
||||
for _, tool := range tools {
|
||||
cmd := newMailSendStub()
|
||||
installMailHook(cmd, "mail", tool, nil)
|
||||
if err := cmd.Flags().Set("attachment", "/tmp/no_such_file_for_"+tool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := cmd.PreRunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("tool %s: expected attachment validation to fire", tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMailHook_NilCmdSafe(t *testing.T) {
|
||||
// Defensive: should not panic.
|
||||
installMailHook(nil, "mail", "send_email", nil)
|
||||
}
|
||||
|
||||
// ── extractFirstMailboxEmail wrap handling ─────────────────────
|
||||
|
||||
func TestExtractFirstMailboxEmail_HandlesRuntimeRunnerWrapping(t *testing.T) {
|
||||
// Mirrors runtimeRunner.executeInvocation: Response is wrapped as
|
||||
// {"endpoint": "...", "content": {emailAccounts: [...]}}.
|
||||
wrapped := map[string]any{
|
||||
"endpoint": "https://mcp.example.com",
|
||||
"content": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "real@example.com", "type": "PERSONAL"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := extractFirstMailboxEmail(wrapped); got != "real@example.com" {
|
||||
t.Fatalf("wrapped extraction failed: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFirstMailboxEmail_HandlesNestedResultUnderContent(t *testing.T) {
|
||||
wrapped := map[string]any{
|
||||
"content": map[string]any{
|
||||
"result": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "nested@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := extractFirstMailboxEmail(wrapped); got != "nested@example.com" {
|
||||
t.Fatalf("nested extraction failed: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFirstMailboxEmail_TextBlockFallback(t *testing.T) {
|
||||
wrapped := map[string]any{
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": `{"emailAccounts":[{"email":"text@example.com"}]}`},
|
||||
},
|
||||
}
|
||||
if got := extractFirstMailboxEmail(wrapped); got != "text@example.com" {
|
||||
t.Fatalf("text-block extraction failed: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFirstMailboxEmail_ReturnsEmptyForEmptyAccounts(t *testing.T) {
|
||||
if extractFirstMailboxEmail(map[string]any{"content": map[string]any{"emailAccounts": []any{}}}) != "" {
|
||||
t.Fatal("expected empty for no accounts")
|
||||
}
|
||||
if extractFirstMailboxEmail(nil) != "" {
|
||||
t.Fatal("expected empty for nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── pickMailboxForUserSearch tier preference ───────────────────
|
||||
|
||||
func TestPickMailboxForUserSearch_PrefersEnterprise(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"content": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
||||
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
email, kind := pickMailboxForUserSearch(resp)
|
||||
if email != "biz@corp.com" {
|
||||
t.Fatalf("expected enterprise pick, got %q", email)
|
||||
}
|
||||
if kind != mailboxKindEnterprise {
|
||||
t.Fatalf("expected enterprise kind, got %v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickMailboxForUserSearch_FallsBackToPersonalWithKind(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"content": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
||||
},
|
||||
},
|
||||
}
|
||||
email, kind := pickMailboxForUserSearch(resp)
|
||||
if email != "personal@dingtalk.com" {
|
||||
t.Fatalf("expected personal email, got %q", email)
|
||||
}
|
||||
if kind != mailboxKindPersonal {
|
||||
t.Fatalf("expected personal kind, got %v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickMailboxForUserSearch_EmptyResponse(t *testing.T) {
|
||||
email, kind := pickMailboxForUserSearch(nil)
|
||||
if email != "" || kind != mailboxKindUnknown {
|
||||
t.Fatalf("expected empty unknown, got email=%q kind=%v", email, kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_PersonalMailboxEmitsSkipMarker(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{
|
||||
"content": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
err := pre(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error to short-circuit personal mailbox")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PAT_MEDIUM_RISK_NO_PERMISSION") {
|
||||
t.Fatalf("missing skip marker, got %v", err)
|
||||
}
|
||||
// Confirm we did NOT mutate --email when refusing.
|
||||
if got, _ := cmd.Flags().GetString("email"); got != "" {
|
||||
t.Fatalf("email should remain unset, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailUserSearchEmailFallback_PrefersEnterpriseOverPersonal(t *testing.T) {
|
||||
runner := &fakeMailboxRunner{resp: map[string]any{
|
||||
"content": map[string]any{
|
||||
"emailAccounts": []any{
|
||||
map[string]any{"email": "p@dingtalk.com", "type": "PERSONAL"},
|
||||
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
pre := newMailUserSearchEmailFallback(runner)
|
||||
cmd := newMailUserSearchStub()
|
||||
if err := pre(cmd, nil); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("email"); got != "biz@corp.com" {
|
||||
t.Fatalf("fallback email = %q, want biz@corp.com", got)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// buildOutputTransform compiles a CLIOutputFormat into a payload-shaping
|
||||
// function applied to executor.Result.Response before the formatter runs.
|
||||
// See discovery-schema-v3 §2.5.
|
||||
//
|
||||
// Apply order (deterministic): drop → rename → columns.
|
||||
// Columns filter only takes effect under --format=table (the formatter
|
||||
// consults it via the "_columns" marker key this function injects).
|
||||
// Returns nil when the spec is empty so callers can skip wiring.
|
||||
func buildOutputTransform(spec market.CLIOutputFormat) func(map[string]any) map[string]any {
|
||||
if len(spec.Drop) == 0 && len(spec.Rename) == 0 && len(spec.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
dropped := append([]string(nil), spec.Drop...)
|
||||
renamed := make(map[string]string, len(spec.Rename))
|
||||
for k, v := range spec.Rename {
|
||||
renamed[k] = v
|
||||
}
|
||||
columns := append([]string(nil), spec.Columns...)
|
||||
|
||||
return func(resp map[string]any) map[string]any {
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
applyDrop(resp, dropped)
|
||||
applyRename(resp, renamed)
|
||||
if len(columns) > 0 {
|
||||
resp["_columns"] = append([]string(nil), columns...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
||||
// applyDrop removes the named keys at the top level and one level of nested
|
||||
// object. Missing keys are silently ignored. Keys with "." are treated as a
|
||||
// two-part path (parent.child).
|
||||
func applyDrop(m map[string]any, keys []string) {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(m, key)
|
||||
}
|
||||
for _, v := range m {
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
delete(inner, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRename moves fields from src key to dst key at top level and one level
|
||||
// of nested object. Collisions overwrite silently. Missing src keys are
|
||||
// no-ops.
|
||||
func applyRename(m map[string]any, mapping map[string]string) {
|
||||
if len(mapping) == 0 {
|
||||
return
|
||||
}
|
||||
// First pass: top level.
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if v, ok := m[src]; ok {
|
||||
m[dst] = v
|
||||
delete(m, src)
|
||||
}
|
||||
}
|
||||
// Second pass: one level nested.
|
||||
for _, v := range m {
|
||||
inner, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for src, dst := range mapping {
|
||||
if src == "" || dst == "" || src == dst {
|
||||
continue
|
||||
}
|
||||
if val, ok := inner[src]; ok {
|
||||
inner[dst] = val
|
||||
delete(inner, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package compat — pipeline executor for CLIToolOverride.Pipeline.
|
||||
//
|
||||
// A pipeline turns a single CLI command into an ordered sequence of MCP
|
||||
// tool calls plus optional HTTP-download sinks, declared entirely in the
|
||||
// envelope JSON. Use cases:
|
||||
//
|
||||
// 1. submit-job + poll-status + download-result patterns (the canonical
|
||||
// example: `dws sheet export --node X --output PATH` calls
|
||||
// submit_export_job → query_export_job (poll until status=done) →
|
||||
// HTTP GET downloadUrl → write to PATH).
|
||||
// 2. compose-then-update flows where step 2's args reference step 1's
|
||||
// response.
|
||||
//
|
||||
// Templates supported in PipelineStep.Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias
|
||||
// equals <aliasName>
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response
|
||||
// literal string — passed through unchanged
|
||||
//
|
||||
// Limitations (intentional, to keep the executor small):
|
||||
// - No conditional branching: steps run unconditionally in order.
|
||||
// - No retry-on-error: the pipeline aborts on the first runner error.
|
||||
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
|
||||
// - Download step uses the standard library net/http with no custom
|
||||
// timeout (relies on the user's Ctrl-C).
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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/market"
|
||||
)
|
||||
|
||||
// pipelineCtx carries flag values + accumulated step responses through
|
||||
// the executor. Unexported because callers always interact via runPipeline.
|
||||
type pipelineCtx struct {
|
||||
flags map[string]string
|
||||
stepOutputs []map[string]any
|
||||
}
|
||||
|
||||
// runPipeline executes route.Pipeline against runner, returning the last
|
||||
// "call"-type step's response (or a synthesized success payload if the
|
||||
// pipeline ends with a "download" step). The map is the shape returned to
|
||||
// the user via the standard output formatter.
|
||||
func runPipeline(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
flagValues map[string]string,
|
||||
) (map[string]any, error) {
|
||||
pctx := &pipelineCtx{
|
||||
flags: flagValues,
|
||||
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
|
||||
}
|
||||
|
||||
var lastCallResponse map[string]any
|
||||
for i, step := range route.Pipeline {
|
||||
stepType := strings.TrimSpace(step.Type)
|
||||
if stepType == "" {
|
||||
stepType = "call"
|
||||
}
|
||||
switch stepType {
|
||||
case "call":
|
||||
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
lastCallResponse = resp
|
||||
case "download":
|
||||
resp, err := executePipelineDownload(cmd, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
default:
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if lastCallResponse != nil {
|
||||
return lastCallResponse, nil
|
||||
}
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
// executePipelineCall resolves args templates, then either polls or fires
|
||||
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
|
||||
// non-empty enable polling.
|
||||
func executePipelineCall(
|
||||
ctx context.Context,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
if strings.TrimSpace(step.Tool) == "" {
|
||||
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
|
||||
}
|
||||
args, err := resolveArgs(step.Args, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invoke := func() (map[string]any, error) {
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
route.Use,
|
||||
route.Target.CanonicalProduct,
|
||||
step.Tool,
|
||||
args,
|
||||
)
|
||||
result, err := runner.Run(ctx, invocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Response == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (cobra
|
||||
// MarkFlagRequired) only checks that the flag was set, not that
|
||||
// the value is non-empty — so a `--required-flag ""` reaches here
|
||||
// and the upstream tool rejects with errorCode. Without this check
|
||||
// the pipeline proceeds to poll/download and either spins until
|
||||
// PollTimeout or burns through retries.
|
||||
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
|
||||
msg := getDotPath(result.Response, "content.errorMessage")
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%s rejected: %s — %v", step.Tool, errCode, msg,
|
||||
))
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(step.PollUntilField) == "" {
|
||||
return invoke()
|
||||
}
|
||||
|
||||
// Polling loop.
|
||||
interval := time.Duration(step.PollIntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
timeoutSec := step.PollTimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 300
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
|
||||
for {
|
||||
resp, err := invoke()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
|
||||
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
|
||||
))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePipelineDownload resolves the URL template, fetches the body via
|
||||
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
|
||||
// Empty output path → print URL to stdout (terminal-friendly mode).
|
||||
func executePipelineDownload(
|
||||
cmd *cobra.Command,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
|
||||
if urlStr == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline download: URL template %q resolved to empty value",
|
||||
step.DownloadURLField,
|
||||
))
|
||||
}
|
||||
|
||||
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
|
||||
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
|
||||
|
||||
// Always print machine-parseable "key: value" lines. Tests and shell
|
||||
// pipelines that consume the pipeline output (regex / awk) rely on
|
||||
// this exact format. The structured JSON output follows via
|
||||
// output.WriteCommandPayload, so AI / SDK callers still get a typed
|
||||
// response.
|
||||
if jobID != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
|
||||
|
||||
if outputPath == "" {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If outputPath is a directory, infer filename from URL basename.
|
||||
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
|
||||
filename := inferFilenameFromURL(urlStr)
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("export_%d", time.Now().Unix())
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", outputPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
written, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
"output": outputPath,
|
||||
"size": written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveArgs applies resolveTemplate to every value in the map.
|
||||
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
|
||||
out := make(map[string]any, len(args))
|
||||
for k, tmpl := range args {
|
||||
v, err := resolveTemplate(tmpl, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("arg %q: %w", k, err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveTemplate evaluates a single template string. Returns the literal
|
||||
// when input does not start with '$'.
|
||||
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
|
||||
s := strings.TrimSpace(tmpl)
|
||||
if !strings.HasPrefix(s, "$") {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
|
||||
dot := strings.Index(s, ".")
|
||||
if dot <= 0 || dot == len(s)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
|
||||
}
|
||||
head := s[:dot]
|
||||
tail := s[dot+1:]
|
||||
|
||||
switch head {
|
||||
case "$flag":
|
||||
// tail is a flag alias name (no nested path supported)
|
||||
return pctx.flags[tail], nil
|
||||
case "$step":
|
||||
// tail format: <idx>.<dotPath>
|
||||
secondDot := strings.Index(tail, ".")
|
||||
if secondDot <= 0 || secondDot == len(tail)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
|
||||
}
|
||||
idxStr := tail[:secondDot]
|
||||
dotPath := tail[secondDot+1:]
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
|
||||
}
|
||||
if idx < 0 || idx >= len(pctx.stepOutputs) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
|
||||
}
|
||||
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
|
||||
default:
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
|
||||
}
|
||||
}
|
||||
|
||||
// getDotPath walks dotPath through nested map[string]any. Returns nil if
|
||||
// any segment is missing or the value isn't a map at an intermediate step.
|
||||
func getDotPath(m map[string]any, dotPath string) any {
|
||||
parts := strings.Split(dotPath, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
nested, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = nested[p]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// inferFilenameFromURL extracts the basename from a URL's path component,
|
||||
// stripping query string + fragment. Returns "" if the URL doesn't parse
|
||||
// or has no useful basename.
|
||||
func inferFilenameFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
if base == "" || base == "/" || base == "." {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
|
||||
// field at top level or one level under common MCP wrappers ("content" /
|
||||
// "result"), so the synthetic download response can echo it back to the
|
||||
// user. Returns "" when no jobId is present anywhere in prior responses.
|
||||
func inferJobIDFromContext(pctx *pipelineCtx) any {
|
||||
candidates := []string{"jobId", "content.jobId", "result.jobId"}
|
||||
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
|
||||
for _, p := range candidates {
|
||||
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
|
||||
// the FlagBinding's primary CLI flag name, so the pipeline executor can
|
||||
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
|
||||
// always included (they are the whole point of the lookup).
|
||||
//
|
||||
// Note on key choice: buildOverrideBindings populates FlagName from the
|
||||
// envelope's `alias` field (or kebab-case of the MCP property name when
|
||||
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
|
||||
// so $flag templates reference the user-visible CLI flag name, e.g.
|
||||
// "$flag.node" matches `--node`.
|
||||
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
|
||||
flags := cmd.Flags()
|
||||
out := make(map[string]string, len(bindings))
|
||||
for _, b := range bindings {
|
||||
primary := strings.TrimSpace(b.FlagName)
|
||||
if primary == "" {
|
||||
primary = strings.TrimSpace(b.Alias)
|
||||
}
|
||||
if primary == "" {
|
||||
continue
|
||||
}
|
||||
// Try the primary flag name first, then any of the extra aliases.
|
||||
// Whichever the user actually set wins; if none was set, the
|
||||
// cobra-level default value is returned.
|
||||
candidates := make([]string, 0, 2+len(b.Aliases))
|
||||
candidates = append(candidates, primary)
|
||||
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
for _, a := range b.Aliases {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
}
|
||||
var value string
|
||||
for _, c := range candidates {
|
||||
f := flags.Lookup(c)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
value = f.Value.String()
|
||||
if f.Changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
out[primary] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
|
||||
// the other side as Go primitives where appropriate. Unused for now —
|
||||
// the resolveTemplate path returns strings as-is to keep the contract
|
||||
// simple; tools that need JSON-shaped values can use the existing
|
||||
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
|
||||
// composition is not in scope for the MVP).
|
||||
var _ = json.Unmarshal
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// The envelope is remote data; none of these malformed shapes may panic the
|
||||
// command build — pflag panics on duplicate long names, duplicate shorthands,
|
||||
// and multi-character shorthands, and a poisoned discovery cache used to take
|
||||
// down every CLI invocation this way (pre-1.0.32 lockout class).
|
||||
func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]market.CLIFlagOverride
|
||||
}{
|
||||
{
|
||||
name: "duplicate shorthand across two flags",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-character shorthand",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "xy"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "primary collides with reserved payload flag",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"params": {},
|
||||
"json": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding duplicate primary via alias",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"user_id": {Alias: "target"},
|
||||
"member_id": {Alias: "target"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding alias collides with another primary",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {},
|
||||
"beta": {Aliases: []string{"alpha"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: tc.flags,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Must not panic; the command must build and stay executable.
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
|
||||
}
|
||||
cmds[0].SetArgs([]string{"boom", "--help"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute --help: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
|
||||
// flags claim the same shorthand, the first (sorted param order) keeps it
|
||||
// and the second still registers its long flag.
|
||||
func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
boom, _, err := cmds[0].Find([]string{"boom"})
|
||||
if err != nil {
|
||||
t.Fatalf("find boom: %v", err)
|
||||
}
|
||||
short := boom.Flags().ShorthandLookup("x")
|
||||
if short == nil || short.Name != "alpha" {
|
||||
t.Fatalf("shorthand -x bound to %v, want alpha", short)
|
||||
}
|
||||
if boom.Flags().Lookup("beta") == nil {
|
||||
t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user