Compare commits

..
Author SHA1 Message Date
修雨 390b6115bf fix(connect): harden daemon restart lifecycle (#599) 2026-07-12 23:11:08 +08:00
修雨 fc9acb9007 fix: align smart category args and runtime network errors (#591)
* fix: align smart category args and remove eval fixtures

* fix: classify runtime network failures

* fix: validate smart category inputs
2026-07-10 21:40:36 +08:00
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
修雨 4c43108bdf sync wukong hardcoded command additions
sync wukong hardcoded command additions

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:57 +08:00
修雨 5e9a920b76 fix(connect): prevent agent mid-turn blocking
fix(connect): prevent agent mid-turn blocking

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:35 +08:00
xuanandshangguanxuan.sgx 36b0528d90 fix: default pat chmod grants to permanent (#584)
* fix: default pat chmod grants to permanent

* docs(changelog): note pat chmod permanent default

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:45 +08:00
xuanandshangguanxuan.sgx a2201b4ab4 test(pat): remove external example auth URL trigger (#583)
* test(pat): remove external example auth URL trigger

* test(app): prevent browser launches during tests

* test(pat): build auth URL fixture as JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:40 +08:00
修雨 181cdf4a03 Merge pull request #578 from LastdianXuan/fix/dek-readonly-keychain-status
fix: keep keychain reads side-effect free
2026-07-08 18:46:33 +08:00
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
张卓澎 fd6bbd928e fix: keep keychain reads side-effect free 2026-07-08 11:23:44 +08:00
张卓澎 67417d3fb1 fix: diagnose macos keychain auth failures 2026-07-08 11:23:44 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
修雨 b794d802f2 release: prepare 1.0.49 stable (#574) 2026-07-08 00:14:57 +08:00
修雨 e6c1dfe15c Merge pull request #570 from DingTalk-Real-AI/fix/release-publish-unblock
ci: unblock npm release from Gitee mirror
2026-07-07 23:46:21 +08:00
修雨 32d32cd827 Merge pull request #572 from audanye-sudo/fix/qa-optimize-6products
fix: resolve real-machine QA findings across CLI, scripts and skill docs
2026-07-07 23:44:00 +08:00
qinze a65d6f23ec fix: resolve real-machine QA findings across CLI, scripts and skill docs
Fix CLI command bugs surfaced by full real-machine QA:
- aitable: make chart/dashboard share update --enabled a string flag so
  "--enabled false" disables instead of silently enabling (bool flag +
  space-syntax help example inverted the action); clarify chart update
  requires --config; make form get filter by view-id client-side so it
  returns a single form; drop inline // comments from chart JSON examples
- chat: resolve conversation-info --user to openDingTalkId, register
  --id/--conversation-id/--chat aliases; cap list-all-conversations
  --limit at 100 and reject larger values instead of silent truncation;
  detect webhook errcode failures instead of wrapping them as success;
  remove duplicate group/members subcommand registration in help
- contact: register --dept/--depts as the primary dept flags to match
  the RunE parsing (were only registered as --id/--ids)
- sheet: emit clean JSON for media-upload and export under --format json,
  suppressing progress lines that leaked onto stdout
- wiki: correct node create --type enum (drop unsupported asheet, add
  axls/able/appt/adraw/amind)
- ding: default message list --type to ALL since the server rejects an
  empty type

Fix helper scripts (mono and multi):
- aitable import/export flag names and tableId length regex
- mail search --limit, contact dept response keys and userInfo nesting
- attendance_my_record whoami compatibility, calendar_schedule_meeting
  event id unwrapping, drive_tree_list recursion via fileId, report
  scripts migrated off deprecated report list/detail

Sync skill docs (mono and multi) to real-machine behavior across all
products: command indexes, flag names, enums, return-structure keys, and
cross-product intent routing; annotate genuinely server-side limitations
and the no-op global --jq/--fields flags.
2026-07-07 23:38:23 +08:00
修雨 a838ae75a7 ci: unblock npm release from Gitee mirror 2026-07-07 22:23:16 +08:00
修雨 238f4256d3 ci: harden Gitee mirror synchronization
Serialize Gitee mirror runs, mirror tag events without touching main, and align Gitee release tags before uploading assets.
2026-07-07 20:48:40 +08:00
修雨 b83e6dc239 release: prepare 1.0.48 stable 2026-07-07 20:48:39 +08:00
修雨 a842560d71 ci: harden gitee mirror synchronization 2026-07-07 20:15:50 +08:00
修雨 d808843f75 feat: seal remove-discovery delivery beta
Merge sealed remove-discovery delivery beta with static endpoint runtime, legacy compatibility aliases, synced skills, yolo connect default, and beta upgrade track.
2026-07-07 19:19:20 +08:00
修雨 6623a6969d docs: sync skills and beta release guidance 2026-07-07 19:15:29 +08:00
修雨 a32d7985e6 refactor: switch to static endpoint delivery runtime 2026-07-07 19:07:03 +08:00
修雨 d3f8e9d712 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon (#558)
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 17:21:44 +08:00
610 changed files with 87338 additions and 62675 deletions
+12
View File
@@ -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
@@ -36,6 +40,14 @@ jobs:
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'
+71
View File
@@ -0,0 +1,71 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
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: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
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
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+82 -4
View File
@@ -5,12 +5,18 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
repair_npm_version:
description: "Only publish an existing release to npm, e.g. v1.0.48"
required: false
type: string
permissions:
contents: write
jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
runs-on: ubuntu-latest
# 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
@@ -76,26 +82,98 @@ jobs:
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
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 }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
timeout-minutes: 20
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
repair-npm:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
runs-on: ubuntu-latest
timeout-minutes: 15
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.repair_npm_version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.repair_npm_version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
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
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -46,4 +46,5 @@ jobs:
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+3
View File
@@ -42,5 +42,8 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
/auto-test/
/eval-runs/
/.qoder/
.vercel
.env*
+51
View File
@@ -0,0 +1,51 @@
name: Gitee Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release tag to build on Gitee, e.g. v1.0.48"
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install packaging tools
run: |
set -eu
sudo apt-get update
sudo apt-get install -y zip unzip curl
- name: Install rcodesign
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: Build and publish Gitee release
env:
VERSION: ${{ inputs.version || github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
run: ./scripts/release/build-and-publish-gitee.sh
+77
View File
@@ -6,6 +6,83 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.51] - 2026-07-10
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
### Added
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
### Changed
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
### Fixed
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
## [1.0.50] - 2026-07-08
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
### Fixed
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
### Changed
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
## [1.0.49] - 2026-07-08
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
### Fixed
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
### Changed
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
## [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.
+75 -24
View File
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -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>
@@ -152,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>
@@ -171,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 |
@@ -300,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
```
@@ -323,7 +331,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
@@ -398,6 +406,51 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
## Features
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
dws event stop <subscribe_id>
```
| Feature | Details |
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
| Shared connection | Consumers for the same user share one local bus and cloud connection |
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
<details>
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
@@ -479,7 +532,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
@@ -488,14 +541,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>
@@ -626,7 +677,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 -20
View File
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -110,6 +110,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
</details>
@@ -152,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>
@@ -171,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` | 跳过技能包更新 |
@@ -300,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
```
@@ -320,7 +328,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
@@ -395,6 +403,51 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
## 功能特性
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
只需要 event 能力时,可以使用官方便捷安装脚本:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
dws event stop <subscribe_id>
```
| 特性 | 说明 |
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
<details>
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
@@ -476,7 +529,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
@@ -485,13 +538,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>
@@ -619,7 +671,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) — 版本历史与迁移说明
+2 -2
View File
@@ -5,7 +5,7 @@
## High-Level Flow
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`) and dynamically loads product commands via `internal/plugin`.
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.
@@ -35,7 +35,7 @@
- `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
- `envelope/`: pre-built discovery payloads for offline use
-227
View File
@@ -1,227 +0,0 @@
# 审计日志(Audit Log)
DWS 自动记录每次 MCP HTTP 调用的审计事件,用于合规追溯和安全审查。默认启用,无需额外配置。
## 功能特性
- **自动记录**:每次命令执行产生一条 JSONL 审计事件
- **按天轮转**:日志文件按日期分割(`audit-YYYYMMDD.jsonl`),默认留存 90 天
- **防篡改**:L1 哈希链(sha256),每条事件链接前一条的 hash,可验证完整性
- **远端转发**:支持 POST 到外部 SIEM 或审计平台
- **三级脱敏**:转发时可按 none/hashed/minimal 脱敏敏感字段
- **CLI 命令**:内置 `dws audit tail/export/verify` 查看和验证
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DWS_AUDIT` | 启用 | 设 `0`/`false`/`off` 关闭审计 |
| `DWS_AUDIT_DIR` | `~/.dws/audit` | 审计日志目录 |
| `DWS_AUDIT_RETENTION_DAYS` | `90` | 日志留存天数 |
| `DWS_AUDIT_FORWARD_URL` | (空) | 远端转发 URL(POST JSON) |
| `DWS_AUDIT_FORWARD_TOKEN` | (空) | 远端转发 Bearer Token |
| `DWS_AUDIT_FORWARD_REDACT` | `none` | 转发脱敏级别:`none`/`hashed`/`minimal` |
## 审计事件格式
每条事件是一行 JSON,字段如下:
```json
{
"ts": "2026-07-06T10:59:06+08:00",
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_id": "agent-xxx",
"actor": {
"user_id": "525018",
"name": "胡奕舟",
"corp_id": "ding8196cd9a2b2405da24f2f5cc6abecb85",
"corp_name": "钉钉"
},
"product": "calendar",
"command": "list_events",
"endpoint": "https://api.dingtalk.com/v1.0/calendar/users/xxx/calendars/primary/events",
"params_summary": "maxResults=20",
"result": "success",
"error_category": "",
"error_reason": "",
"duration_ms": 234,
"cli_version": "1.0.47",
"os": "darwin",
"arch": "arm64",
"prev_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
**字段说明**:
- `ts`:事件时间戳(RFC3339)
- `execution_id`:本次命令执行的唯一 ID
- `agent_id`:Agent 标识(若有)
- `actor`:执行者信息(从登录态获取)
- `product`:调用的产品(如 `calendar`、`chat`、`contact`)
- `command`:调用的命令(如 `list_events`、`send_message`)
- `endpoint`:实际请求的 API 端点(已脱敏)
- `params_summary`:参数摘要(已脱敏)
- `result`:`success` 或 `error`
- `error_category` / `error_reason`:错误分类和原因(成功时为空)
- `duration_ms`:命令执行耗时(毫秒)
- `prev_hash` / `hash`:哈希链字段,用于防篡改验证
## CLI 命令
### 查看最近日志
```bash
# 查看最近 20 条审计事件(默认)
dws audit tail
# 查看最近 50 条
dws audit tail -n 50
```
输出示例:
```
2026-07-06 10:59:06 calendar list_events user=525018 result=success 234ms
2026-07-06 10:58:42 chat send_message user=525018 result=success 156ms
2026-07-06 10:57:15 contact search user=525018 result=success 89ms
```
### 导出日志
```bash
# 导出最近 7 天的 JSONL
dws audit export --since 2026-06-29 --until 2026-07-06 --format jsonl > audit-7d.jsonl
# 导出为 CSV(方便 Excel 打开)
dws audit export --since 2026-07-01 --format csv > audit-july.csv
```
### 验证哈希链
```bash
# 验证当前最新日志文件的哈希链完整性
dws audit verify
# 验证指定文件
dws audit verify --file ~/.dws/audit/audit-20260706.jsonl
```
输出:
```
✓ 哈希链完整:234 条事件全部校验通过
```
或:
```
✗ 哈希链断裂:第 156 条事件的 hash 不匹配
```
## 哈希链防篡改
每条事件的 `hash` 字段由以下公式计算:
```
hash = sha256(prev_hash + event_json_without_hash_fields)
```
- 首条事件的 `prev_hash` 为空字符串
- 后续事件的 `prev_hash` = 前一条的 `hash`
- 任何对历史事件的篡改都会导致后续所有 hash 失效
**验证流程**:
1. 读取日志文件,逐行解析
2. 对每条事件,移除 `prev_hash` 和 `hash` 字段,重新序列化
3. 用前一条的 hash + 当前事件 JSON 计算新 hash
4. 对比计算结果与文件中记录的 hash
5. 全部匹配 = 完整;某条不匹配 = 被篡改
## 远端转发
设置 `DWS_AUDIT_FORWARD_URL` 后,每条审计事件会异步 POST 到指定端点:
```bash
export DWS_AUDIT_FORWARD_URL=https://siem.example.com/ingest/audit
export DWS_AUDIT_FORWARD_TOKEN=your-bearer-token # 可选
```
**请求格式**:
```http
POST /ingest/audit HTTP/1.1
Host: siem.example.com
Authorization: Bearer your-bearer-token
Content-Type: application/json
{"ts":"2026-07-06T10:59:06+08:00","product":"calendar",...}
```
**超时与重试**:3 秒超时,失败不阻塞命令执行(best-effort),不自动重试。
## 脱敏分级
转发时可通过 `DWS_AUDIT_FORWARD_REDACT` 控制脱敏级别:
| 级别 | 行为 | 适用场景 |
|------|------|----------|
| `none`(默认) | 原样转发,不脱敏 | 内部审计平台 |
| `hashed` | actor.name 哈希化,params_summary 脱敏 | 跨部门共享 |
| `minimal` | 仅保留 ts/product/command/result/duration_ms,移除 actor/endpoint/params | 对外合规报告 |
**示例**:
```bash
# 最小化脱敏(仅保留元数据)
export DWS_AUDIT_FORWARD_REDACT=minimal
export DWS_AUDIT_FORWARD_URL=https://compliance.example.com/audit
```
## 常见问题
### Q: 审计日志占多少磁盘?
典型场景(每天 100 条命令)约 50KB/天,90 天约 4.5MB。日志文件是 JSONL 纯文本,gzip 压缩后约 1/5。
### Q: 关闭审计会影响性能吗?
设置 `DWS_AUDIT=0` 后,审计模块不初始化,零开销。默认启用时,每条事件写入耗时 <1ms(异步磁盘 IO)。
### Q: 哈希链断了怎么办?
可能原因:
1. 手动编辑过日志文件
2. 磁盘损坏
3. 并发写入导致顺序错乱(罕见)
**处理**:
- 备份当前日志
- 用 `dws audit verify` 定位断裂位置
- 从断裂点之后的事件可继续验证(前缀已不可信)
### Q: 如何清理旧日志?
自动清理:`DWS_AUDIT_RETENTION_DAYS=90`(默认),超过 90 天的文件在下次启动时 best-effort 删除。
手动清理:
```bash
# 删除 2026 年 6 月之前的日志
rm ~/.dws/audit/audit-202605*.jsonl
```
## 技术实现
- **核心包**:`internal/audit`
- **集成点**:`internal/app/runner.go` 的 `executeInvocation` 方法(defer 调用 `emitAudit`)
- **身份获取**:从 `auth.LoadTokenData` 读取当前登录用户
- **参数脱敏**:调用 `logging.SanitizeArguments`(与现有日志脱敏逻辑一致)
## 相关文档
- [环境变量参考](./reference.md)
- [架构概览](./architecture.md)
- [自动化与脚本](./automation.md)
+12 -16
View File
@@ -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 / 自动补全
+4 -3
View File
@@ -1,17 +1,19 @@
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.8
go 1.25.9
require (
github.com/Microsoft/go-winio v0.6.2
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/gorilla/websocket v1.5.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/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -35,7 +37,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
+4 -2
View File
@@ -1,5 +1,7 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -86,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+47 -30
View File
@@ -446,6 +446,7 @@ func newAuthStatusCommand() *cobra.Command {
authenticated := false
refreshed := false
var tokenData *authpkg.TokenData
var statusErr error
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := provider.Status(); err == nil {
@@ -468,12 +469,15 @@ func newAuthStatusCommand() *cobra.Command {
if authStatusAuthenticated(tokenData) {
authenticated = true
}
} else {
statusErr = err
}
diagnostic := authStatusDiagnosticFromError(statusErr)
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
}
// Default table output
@@ -503,7 +507,10 @@ func newAuthStatusCommand() *cobra.Command {
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
if !edition.Get().IsEmbedded {
if diagnostic != nil {
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
} else if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
}
}
@@ -562,24 +569,6 @@ func pushRuntimeProfile(selector string) func() {
}
}
func cleanupAuthConfigIfNoProfiles(configDir string) {
cfg, err := authpkg.LoadProfiles(configDir)
if err == nil && len(cfg.Profiles) > 0 {
return
}
if authpkg.TokenDataExistsKeychain() {
return
}
appKey, _ := authpkg.ResolveAppCredentials(configDir)
if appKey != "" {
_ = authpkg.DeleteAppTokenData(appKey)
}
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteTokenMarker(configDir)
}
func newAuthExportCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "export",
@@ -956,10 +945,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)
}
@@ -1028,10 +1013,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) {
@@ -1224,6 +1206,8 @@ type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Hint string `json:"hint,omitempty"`
Refreshed bool `json:"refreshed,omitempty"`
TokenValid bool `json:"token_valid,omitempty"`
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
@@ -1235,14 +1219,47 @@ type authStatusResponse struct {
UserName string `json:"user_name,omitempty"`
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
type authStatusDiagnostic struct {
Reason string
Message string
Hint string
}
func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
if err == nil {
return nil
}
if keychain.IsDEKMissing(err) {
return &authStatusDiagnostic{
Reason: "dek_missing",
Message: "本地登录密钥缺失,无法解密已保存的登录态",
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
}
}
if !keychain.IsUnavailable(err) {
return nil
}
return &authStatusDiagnostic{
Reason: "keychain_unavailable",
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
}
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
resp := authStatusResponse{
Success: true,
Authenticated: authenticated,
}
if !authenticated {
resp.Message = "未登录"
if diagnostic != nil {
resp.Message = diagnostic.Message
resp.Reason = diagnostic.Reason
resp.Hint = diagnostic.Hint
} else {
resp.Message = "未登录"
}
} else if data != nil {
resp.Refreshed = refreshed
resp.TokenValid = data.IsAccessTokenValid()
+106
View File
@@ -16,7 +16,9 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -132,6 +134,106 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
}
}
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "keychain_unavailable" {
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
}
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
}
}
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "dek_missing" {
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
}
if !strings.Contains(resp.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
}
}
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
// Isolate keychain storage to a per-test directory so the saved
// token can't leak into other test packages running in parallel.
@@ -824,6 +926,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
if got == nil {
return len(want) == 0
-213
View File
@@ -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")
}
}
+78
View File
@@ -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)
}
}
+8 -192
View File
@@ -1,203 +1,19 @@
// 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"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
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 {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
return cmd.Help()
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+7 -7
View File
@@ -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)
}
-198
View File
@@ -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)
}
}
}
+71 -61
View File
@@ -21,8 +21,7 @@ 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/keychain"
"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"
@@ -31,6 +30,8 @@ import (
"github.com/spf13/cobra"
)
var doctorKeychainDiagnose = keychain.Diagnose
// checkStatus represents the outcome of a single doctor check.
type checkStatus string
@@ -78,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
checks = append(checks, authResult)
keychainResult := doctorCheckKeychain(w, jsonOut)
checks = append(checks, keychainResult)
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
@@ -134,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
data, err := provider.Status()
if err != nil || data == nil {
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
r := checkResult{
Name: "auth",
Status: statusFail,
Message: diagnostic.Message,
Hint: diagnostic.Hint,
Detail: map[string]string{"reason": diagnostic.Reason},
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
if !edition.Get().IsEmbedded {
r.Hint = "运行 dws auth login 进行登录"
@@ -184,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
return r
}
// ── Keychain check ─────────────────────────────────────────────────────
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
}
diagnostic := doctorKeychainDiagnose()
r := checkResult{
Name: "keychain",
Status: statusPass,
Message: diagnostic.Message,
Detail: diagnostic.Detail,
}
if !diagnostic.OK {
r.Status = statusFail
r.Hint = diagnostic.Hint
if diagnostic.Detail == nil {
r.Detail = map[string]string{"reason": diagnostic.Reason}
} else if diagnostic.Reason != "" {
detail := make(map[string]string, len(diagnostic.Detail)+1)
for k, v := range diagnostic.Detail {
detail[k] = v
}
detail["reason"] = diagnostic.Reason
r.Detail = detail
}
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Network check ───────────────────────────────────────────────────────
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
@@ -193,15 +244,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 +263,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 +297,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)
+113 -7
View File
@@ -15,9 +15,16 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCountResults(t *testing.T) {
@@ -108,11 +115,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,14 +126,116 @@ 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")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
}
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
}
}
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; result=%+v", r)
}
if !strings.Contains(r.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["reason"] != "dek_missing" {
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
}
}
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
prev := doctorKeychainDiagnose
doctorKeychainDiagnose = func() keychain.Diagnostic {
return keychain.Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串不存在",
Hint: "恢复默认钥匙串后重试",
Detail: map[string]string{
"default_keychain": "/tmp/missing.keychain-db",
},
}
}
t.Cleanup(func() {
doctorKeychainDiagnose = prev
})
var buf bytes.Buffer
r := doctorCheckKeychain(&buf, false)
if r.Name != "keychain" {
t.Fatalf("name = %q, want keychain", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if r.Message != "macOS 默认钥匙串不存在" {
t.Fatalf("message = %q", r.Message)
}
if r.Hint == "" {
t.Fatalf("hint is empty; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["default_keychain"] == "" {
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
}
}
func TestDoctorCommandStructure(t *testing.T) {
cmd := newDoctorCommand()
if cmd.Use != "doctor" {
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
// 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"
"os"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) {
t.Helper()
raw, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshal app config: %v", err)
}
if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil {
t.Fatalf("write app config: %v", err)
}
}
func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) {
t.Setenv(authpkg.EnvClientID, "")
t.Setenv(authpkg.EnvClientSecret, "")
dir := t.TempDir()
clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{
Mode: source.PortalTicketModeNormal,
SourceID: "pre_open_source",
})
if err != nil {
t.Fatalf("resolveEventCredentials: %v", err)
}
if clientID != "portal-ticket-normal:pre_open_source" {
t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID)
}
if clientSecret != "" {
t.Fatalf("clientSecret = %q, want empty", clientSecret)
}
}
func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) {
t.Setenv(authpkg.EnvClientID, "")
t.Setenv(authpkg.EnvClientSecret, "")
dir := t.TempDir()
writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"})
_, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{
Mode: source.PortalTicketModeCustom,
})
if !errors.Is(err, authpkg.ErrClientSecretEmpty) {
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
}
}
+845
View File
@@ -0,0 +1,845 @@
// 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type commonConsumeOptions struct {
EventTypes []string
Filter string
Compact bool
FormatRaw string
OutputDir string
RoutesRaw []string
MaxEvents int
Duration time.Duration
Quiet bool
Force bool
DryRun bool
Foreground bool
}
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
DebugRawEvents bool
SubscribeID string
Rule string
Name string
FilterJSON string
QueryCSV string
TTL time.Duration
Ephemeral bool
UserID string
GroupID string
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type personalListOptions struct {
Category string
EnabledOnly bool
IncludePending bool
Format string
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
}
type personalStreamSourceOptions struct {
ConfigDir string
Identity personal.Identity
TicketMode string
TicketURL string
ClientIDOverride string
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as != "user" {
return fmt.Errorf("event schema is only supported with --as user")
}
def, ok := personal.Lookup(args[0])
if !ok {
return fmt.Errorf("unknown personal event key %q", args[0])
}
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
hideEventInternalFlags(cmd, "as")
return cmd
}
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(items)
}
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
it.EventKey, it.RuleType, it.Status, it.Description)
}
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
}
if format != "json" {
return fmt.Errorf("event schema only supports json output")
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if opts.Common.DryRun {
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return consume.Run(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
}
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
}
cleanup := func() {
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
}
if opts.Ephemeral {
defer cleanup()
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := consume.ValidateConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
if !opts.Ephemeral {
cleanup()
}
return err
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
}
bus.ApplyEnvTuning(&busCfg)
err = bus.Run(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
err = consume.Run(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
if cfg == nil {
return
}
if opts.DebugRawEvents {
cfg.EventTypes = nil
cfg.Filter = ""
cfg.SubscribeID = ""
return
}
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
cfg.Filter = opts.Common.Filter
cfg.SubscribeID = strings.TrimSpace(subscribeID)
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
ruleType = def.RuleType
}
}
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
return sub, eventKey, ruleType, nil
}
if strings.TrimSpace(opts.EventKey) == "" {
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
GroupID: opts.GroupID,
})
if err != nil {
return nil, "", "", err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return nil, "", "", err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := client.CreateSubscription(ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
var qs busctl.EntryStatus
if entry != nil {
qs = busctl.QueryEntry(*entry)
} else {
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
WorkDir: workDir,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
ClientIDHash: identityHash,
IdentityHash: identityHash,
State: busctl.BusStateNotRunning,
Meta: &bus.Meta{
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
},
}}
}
status := opts.Status
if status == "" || status == "all" {
status = ""
}
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
})
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"identity": redactedPersonalIdentity(identity, identityHash),
"subscriptions": subs,
"bus": qs,
})
}
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
return nil
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
}
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
if qs.Entry.HolderPID > 0 {
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
}
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
if len(subs) == 0 {
fmt.Fprintln(w, "Subscriptions: none")
} else {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
for _, sub := range subs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
}
_ = tw.Flush()
}
renderPersonalConsumers(w, qs)
}
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
if qs.Entry.State != busctl.BusStateRunning {
fmt.Fprintln(w, "Consumers: none")
return
}
if qs.Live == nil {
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
return
}
if len(qs.Live.Consumers) == 0 {
fmt.Fprintln(w, "Consumers: none")
return
}
fmt.Fprintln(w, "Consumers:")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
for _, cs := range qs.Live.Consumers {
eventKeys := strings.Join(cs.EventTypes, ",")
if eventKeys == "" {
eventKeys = "(catch-all)"
}
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
filter := displayPersonalStatusValue(cs.Filter)
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
}
_ = tw.Flush()
}
func displayPersonalStatusValue(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "-"
}
return v
}
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
ctx := c.Context()
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
isSingleTarget := explicitSubscribeID != ""
if explicitSubscribeID != "" && opts.All {
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
}
if explicitSubscribeID == "" && !opts.All {
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := client.DeleteSubscription(ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
}
}
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
remaining, err := personal.LoadRunStates(workDir)
if err != nil {
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
}
if len(remaining) > 0 {
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
return nil
}
busState := "personal bus stopped"
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
return err
}
}
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
return nil
}
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
explicit = strings.TrimSpace(explicit)
if explicit != "" && all {
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
}
if explicit != "" {
return []string{explicit}, nil
}
if !all {
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
}
states, err := personal.LoadRunStates(workDir)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(states))
for _, st := range states {
if st.SubscribeID != "" {
ids = append(ids, st.SubscribeID)
}
}
sort.Strings(ids)
return ids, nil
}
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
targets := make(map[string]struct{}, len(subscribeIDs))
for _, id := range subscribeIDs {
id = strings.TrimSpace(id)
if id != "" {
targets[id] = struct{}{}
}
}
if ipcEndpoint == "" || len(targets) == 0 {
return nil
}
status, err := busctl.QueryStatus(ipcEndpoint)
if err != nil {
return nil
}
signalled := make(map[int]struct{})
for _, consumer := range status.Consumers {
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
continue
}
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
continue
}
if _, ok := signalled[consumer.PID]; ok {
continue
}
proc, err := os.FindProcess(consumer.PID)
if err != nil {
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
}
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
}
signalled[consumer.PID] = struct{}{}
}
return nil
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
return
}
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := authpkg.LoadTokenData(configDir)
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
userID = tokenData.UserID
clientID = tokenData.ClientID
refreshToken = tokenData.RefreshToken
}
if corpID == "" {
corpID = resolveRuntimeDefault(ctx, "$corpId")
}
if userID == "" {
userID = resolveRuntimeDefault(ctx, "$currentUserId")
}
if clientID == "" {
clientID = authpkg.ClientID()
}
if clientID == "" {
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
clientID = id
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
localSubject = personalTokenSubject("refresh", refreshToken)
if localSubject == "" {
localSubject = personalTokenSubject("access", accessToken)
}
}
return personal.Identity{
AccessToken: accessToken,
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
if v, ok := fn(ctx); ok {
return strings.TrimSpace(v)
}
}
}
return ""
}
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
mode := strings.TrimSpace(opts.TicketMode)
if mode == "" {
mode = "normal"
}
if mode != "normal" && mode != "custom" {
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
}
ticketURL := strings.TrimSpace(opts.TicketURL)
if ticketURL == "" {
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
}
clientID := opts.Identity.ClientID
clientSecret := ""
if mode == "custom" {
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
if err != nil {
return nil, err
}
if opts.ClientIDOverride != "" {
clientID = opts.ClientIDOverride
} else if clientID == "" {
clientID = resolvedID
}
clientSecret = secret
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessToken: opts.Identity.AccessToken,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--stream-source-id", identity.SourceID,
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", ticketMode)
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", ticketURL)
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
}
if strings.TrimSpace(eventKey) == "" {
return nil
}
return []string{eventKey}
}
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
return map[string]string{
"corp_id": displayIdentityPart(identity.CorpID),
"user_id": displayIdentityPart(identity.UserID),
"client_id": identity.ClientID,
"source_id": identity.SourceID,
"identity_hash": identityHash,
}
}
func displayIdentityPart(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "unknown"
}
return v
}
func firstNonEmptyPersonalString(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
}
func personalEventStreamTicketURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
}
func personalEventStreamSourceID(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
return v
}
return "open"
}
func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
}
func configuredMCPBaseURL(configDir string) string {
if strings.TrimSpace(configDir) == "" {
configDir = defaultConfigDir()
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
+131
View File
@@ -0,0 +1,131 @@
// 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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
cfg := consume.Config{}
opts := personalConsumeOptions{
DebugRawEvents: true,
Common: commonConsumeOptions{
EventTypes: []string{"should-not-survive"},
Filter: "^should-not-survive$",
},
}
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
}
}
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
cfg := consume.Config{}
opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}}
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" {
t.Fatalf("eventTypes = %#v", cfg.EventTypes)
}
if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" {
t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
cmd := newEventConsumeCommand()
for _, name := range []string{"user", "group", "query"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("flag --%s is not registered", name)
}
}
for _, name := range []string{
"peer-user-id",
"peer-union-id",
"sender-user-id",
"sender-union-id",
"open-conversation-id",
"keyword",
} {
if cmd.Flags().Lookup(name) != nil {
t.Fatalf("retired flag --%s is still registered", name)
}
}
}
func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
for _, name := range []string{
"peer-user-id",
"peer-union-id",
"sender-user-id",
"sender-union-id",
"open-conversation-id",
"keyword",
} {
t.Run(name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) {
t.Fatalf("Execute() error = %v, want unknown flag", err)
}
})
}
}
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
for _, args := range [][]string{
{"--as", "app", "--user", "507971"},
{"--as", "app", "--group", "cid"},
{"--as", "app", "--query", "报警"},
} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err)
}
}
}
@@ -0,0 +1,211 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
if identity.LocalSubject != "" {
t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject)
}
wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source"
if got := identity.Key(); got != wantKey {
t.Fatalf("identity key = %q, want %q", got, wantKey)
}
}
func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
wantSubject := personalTokenSubject("refresh", "refresh-1")
if identity.LocalSubject != wantSubject {
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
}
if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") {
t.Fatalf("identity key leaked raw token: %q", identity.Key())
}
body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1"))
if err != nil {
t.Fatalf("marshal redacted identity: %v", err)
}
if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") {
t.Fatalf("redacted identity leaked local subject/token: %s", string(body))
}
if !strings.Contains(string(body), "unknown") {
t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body))
}
}
func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
ExpiresAt: time.Now().Add(time.Hour),
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
wantSubject := personalTokenSubject("access", "access-1")
if identity.LocalSubject != wantSubject {
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
}
var out bytes.Buffer
renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning},
})
rendered := out.String()
if !strings.Contains(rendered, "corp=unknown user=unknown") {
t.Fatalf("status output = %q, want unknown corp/user", rendered)
}
if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") {
t.Fatalf("status output leaked local subject/token: %q", rendered)
}
}
func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
ExpiresAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
if identity.SourceID != "open" {
t.Fatalf("SourceID = %q, want open", identity.SourceID)
}
}
func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
prev := edition.Get()
edition.Override(&edition.Hooks{})
t.Cleanup(func() { edition.Override(prev) })
if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" {
t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got)
}
if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" {
t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got)
}
if got := personalEventStreamSourceID(""); got != "open" {
t.Fatalf("personalEventStreamSourceID() = %q, want open", got)
}
if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" {
t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got)
}
}
func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil {
t.Fatalf("write mcp_url: %v", err)
}
if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" {
t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got)
}
if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" {
t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got)
}
if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" {
t.Fatalf("explicit control URL = %q, want trimmed override", got)
}
if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" {
t.Fatalf("explicit ticket URL = %q, want trimmed override", got)
}
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
t.Fatalf("explicit sourceID = %q, want flag_source", got)
}
}
func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"})
t.Cleanup(func() { edition.Override(prev) })
if got := personalEventStreamSourceID(""); got != "edition_source" {
t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got)
}
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
t.Fatalf("explicit sourceID = %q, want flag_source", got)
}
}
func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string {
t.Helper()
configDir := t.TempDir()
raw, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal token data: %v", err)
}
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(dir string) ([]byte, error) {
if filepath.Clean(dir) != filepath.Clean(configDir) {
t.Fatalf("LoadToken dir = %q, want %q", dir, configDir)
}
return raw, nil
},
})
t.Cleanup(func() { edition.Override(prev) })
return configDir
}
+349
View File
@@ -0,0 +1,349 @@
// 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"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "table", args: []string{"--as", "user"}},
{name: "json", args: []string{"--as", "user", "--format", "json"}},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
assertPersonalOutputHidesSchemaIDs(t, got)
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
}
})
}
}
func TestEventListDefaultsToUser(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
t.Fatalf("list output = %s, want personal event catalog", got)
}
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
}
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
t.Fatalf("list default appears to use legacy application output: %s", got)
}
}
func TestEventPublicHelpHidesAppMode(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
}{
{name: "consume", cmd: newEventConsumeCommand()},
{name: "list", cmd: newEventListCommand()},
{name: "schema", cmd: newEventSchemaCommand()},
{name: "status", cmd: newEventStatusCommand()},
{name: "stop", cmd: newEventStopCommand()},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
tc.cmd.SetOut(&out)
tc.cmd.SetArgs([]string{"--help"})
if tc.name == "schema" {
tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"})
}
if err := tc.cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} {
if strings.Contains(got, hidden) {
t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got)
}
}
})
}
}
func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--all"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") {
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
}
}
func TestEventAsAppRejected(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, cmd := range []*cobra.Command{
newEventListCommand(),
newEventStatusCommand(),
newEventConsumeCommand(),
newEventStopCommand(),
newEventSchemaCommand(),
} {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app"})
if cmd.Use == "schema <event_key>" {
cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"})
}
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err)
}
}
}
func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
cmd := newEventStatusCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--all", "--fail-on-orphan"})
err := cmd.Execute()
if err == nil ||
!strings.Contains(err.Error(), "--all") ||
!strings.Contains(err.Error(), "--fail-on-orphan") ||
!strings.Contains(err.Error(), "not supported for personal events") {
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
}
}
func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "default", args: []string{personal.EventSingleChat, "--as", "user"}},
{name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
assertPersonalOutputHidesSchemaIDs(t, out.String())
if strings.Contains(out.String(), "Schemas") {
t.Fatalf("schema output contains Schemas line: %s", out.String())
}
})
}
}
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
for _, eventKey := range []string{
personal.EventMention,
personal.EventSingleChat,
personal.EventInChat,
} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got)
}
for _, want := range []string{
"event_key",
"display_name",
"description",
"category",
"rule_type",
"required_params",
"jq_root_path",
"schema",
"event_id",
"timestamp",
"subscribe_id",
"content",
"sender",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
} {
if !strings.Contains(got, want) {
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
}
}
for _, leaked := range []string{
"message.text",
"chat.openConversationId",
"sender.userId",
"sender.unionId",
"auth",
"resolved_output_schema",
"decoded_data_schema",
"filter_schema",
"payload_schema",
"output_schema",
"data_json_path",
"headers",
"audit",
"tenant",
"subject",
"traceId",
"msgIdMetaq",
"at_users",
"sender_user_id",
} {
if strings.Contains(got, leaked) {
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
}
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v, want object", doc["schema"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
}
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
}
})
}
}
func TestEventSchemaDefaultsToUser(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{personal.EventSingleChat})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != personal.EventSingleChat {
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
}
}
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
args []string
}{
{
name: "schema",
cmd: newEventSchemaCommand(),
args: []string{personal.EventFromUser},
},
{
name: "consume",
cmd: newEventConsumeCommand(),
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
},
{
name: "status",
cmd: newEventStatusCommand(),
args: []string{"--event", personal.EventFromUser},
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.cmd.SilenceUsage = true
tc.cmd.SilenceErrors = true
tc.cmd.SetArgs(tc.args)
err := tc.cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
t.Fatalf("Execute() error = %v, want not publicly available", err)
}
})
}
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") {
t.Fatalf("Execute() error = %v, want json-only format validation", err)
}
}
func TestEventAsBotRejected(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) {
t.Helper()
for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} {
if strings.Contains(out, leaked) {
t.Fatalf("output leaked %q: %s", leaked, out)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
// 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/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
SourceID: "source-1",
}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateRunning,
HolderPID: 100,
},
Live: &transport.StatusResp{
Consumers: []transport.StatusConsumer{
{
PID: 12345,
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "subId-1",
Filter: "content",
Received: 3,
Dropped: 1,
},
{
PID: 12346,
Received: 5,
},
},
},
})
got := out.String()
for _, want := range []string{
"Subscriptions: none",
"Consumers:",
"PID",
"EVENT_KEYS",
"SUBSCRIBE_ID",
"RECEIVED",
"DROPPED",
"12345",
"user_im_message_receive_o2o",
"subId-1",
"content",
"3",
"1",
"(catch-all)",
"-",
} {
if !strings.Contains(got, want) {
t.Fatalf("status output missing %q:\n%s", want, got)
}
}
}
func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateRunning,
HolderPID: 100,
},
})
if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") {
t.Fatalf("status output = %q, want unavailable consumers", got)
}
}
func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateNotRunning,
},
})
if got := out.String(); !strings.Contains(got, "Consumers: none") {
t.Fatalf("status output = %q, want no consumers", got)
}
}
+127
View File
@@ -0,0 +1,127 @@
// 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"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) {
cmd := newEventStopCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
for _, want := range []string{
"stop [subscribe_id]",
"取消个人事件订阅并停止本地消费",
"取消个人事件订阅并停止本地消费,清理对应本地消费状态",
} {
if !strings.Contains(got, want) {
t.Fatalf("help missing %q:\n%s", want, got)
}
}
for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} {
if strings.Contains(got, stale) {
t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got)
}
}
}
func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
t.Fatalf("Execute() error = %v, want subscribe_id requirement", err)
}
}
func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"subId-1", "--all"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") {
t.Fatalf("Execute() error = %v, want mutual exclusion", err)
}
}
func TestEventStopAsAppRejectsSubscribeID(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", "subId-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestPersonalStopTargets(t *testing.T) {
workDir := t.TempDir()
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil {
t.Fatalf("UpsertRunState() error = %v", err)
}
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil {
t.Fatalf("UpsertRunState() error = %v", err)
}
got, err := personalStopTargets(workDir, "sub-explicit", false)
if err != nil {
t.Fatalf("personalStopTargets(explicit) error = %v", err)
}
if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) {
t.Fatalf("explicit targets = %#v, want %#v", got, want)
}
got, err = personalStopTargets(workDir, "", true)
if err != nil {
t.Fatalf("personalStopTargets(all) error = %v", err)
}
if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("all targets = %#v, want %#v", got, want)
}
if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
t.Fatalf("personalStopTargets(no target) error = %v, want required error", err)
}
if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err)
}
}
func TestPrintPersonalStopResult(t *testing.T) {
var out bytes.Buffer
printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped")
if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" {
t.Fatalf("single output = %q", got)
}
out.Reset()
printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running")
if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" {
t.Fatalf("multi output = %q", got)
}
}
-354
View File
@@ -1,354 +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()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"chat message list-topic-replies",
} {
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 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, 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 TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, 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 executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
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 -598
View File
@@ -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,456 +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)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// 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
}
@@ -667,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).
-743
View File
@@ -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/cedar":
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/cedar":
_ = 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")
}
}
-203
View File
@@ -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))
}
}
-219
View File
@@ -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)
}
})
}
}
+37 -6
View File
@@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
return string(data)
}
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
}
payload := struct {
Code string `json:"code"`
Data patData `json:"data"`
}{
Code: "AGENT_CODE_NOT_EXISTS",
Data: patData{
Desc: "test auth",
FlowID: flowID,
AuthorizationURL: authURL,
ClientID: clientID,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func patTestAuthorizationURL(server *httptest.Server) string {
return server.URL + "/pat"
}
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
@@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, _ := setupHandlePATServer(t, "APPROVED", "")
server, configDir := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var retried atomic.Bool
var retryHadKey atomic.Bool
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
@@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
server, _ := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
fallback: mock,
globalFlags: &GlobalFlags{Format: "table"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
authURL := patTestAuthorizationURL(server)
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
if !strings.Contains(buf.String(), "需要 PAT 授权") {
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
}
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
}
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
@@ -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)
}
})
}
+13 -107
View File
@@ -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,72 +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)
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
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
}
-398
View File
@@ -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)
}
})
}
}
@@ -1,158 +0,0 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"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/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
-50
View File
@@ -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)")
}
}
-324
View File
@@ -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)
}
}
+47 -689
View File
@@ -15,7 +15,6 @@ package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
@@ -23,7 +22,6 @@ import (
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"sync"
@@ -31,15 +29,10 @@ 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/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
@@ -48,8 +41,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/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"
"github.com/spf13/pflag"
)
@@ -303,12 +296,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
AuthTokenFunc: func(ctx context.Context) string {
return resolveRuntimeAuthToken(ctx, "")
},
LoggerFunc: FileLoggerInstance,
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
@@ -355,8 +343,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
bindPersistentFlags(root, flags)
schemaCmd := newSchemaCommand(loader)
genSkillsCmd := newGenerateSkillsCommand()
genSkillsCmd.Hidden = true
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
patCaller := newToolCallerAdapter(runner, flags)
@@ -370,23 +356,22 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newEventCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
schemaCmd,
genSkillsCmd,
mcpCmd,
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so that
// AppendDynamicServer adds plugin endpoints on top of Market
// endpoints (SetDynamicServers is called inside loadDynamicCommands).
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := loadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
@@ -497,165 +482,6 @@ func newSkillCommand() *cobra.Command {
return buildSkillCommand()
}
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
statusCmd := &cobra.Command{
Use: "status",
Short: "查看缓存状态",
RunE: func(cmd *cobra.Command, args []string) error {
jsonOut, err := cmd.Flags().GetBool("json")
if err != nil {
return apperrors.NewInternal("failed to read cache status flags")
}
store := cacheStoreFromEnv()
files, bytes, err := cacheDirectoryStats(store.Root)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read cache status: %v", err))
}
// Enumerate per-server tools cache entries.
partition := config.DefaultPartition
entries, _ := store.ListToolsCacheEntries(partition)
payload := map[string]any{
"kind": "cache_status",
"cache_root": store.Root,
"files": files,
"bytes": bytes,
}
if len(entries) > 0 {
toolEntries := make([]map[string]any, 0, len(entries))
for _, e := range entries {
toolEntries = append(toolEntries, map[string]any{
"server_key": e.ServerKey,
"freshness": string(e.Freshness),
"saved_at": e.SavedAt.Format(time.RFC3339),
"tool_count": e.ToolCount,
"ttl_remaining": e.TTLRemaining,
})
}
payload["tools"] = toolEntries
}
if jsonOut {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "缓存目录: %s\n文件数: %d 大小: %d 字节\n", store.Root, files, bytes)
if len(entries) > 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n工具缓存:")
for _, e := range entries {
age := ""
if !e.SavedAt.IsZero() {
dur := time.Since(e.SavedAt).Truncate(time.Minute)
age = fmt.Sprintf(",%s 前保存", dur)
}
ttl := ""
if e.TTLRemaining != "" {
ttl = fmt.Sprintf(",剩余 TTL %s", e.TTLRemaining)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s (%s%s,%d 个工具%s)\n",
e.ServerKey, string(e.Freshness), age, e.ToolCount, ttl)
}
}
return nil
},
}
statusCmd.Flags().Bool("json", false, "Emit cache status as JSON")
refreshCmd := &cobra.Command{
Use: "refresh",
Short: "强制刷新工具缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache refresh flags")
}
store := cacheStoreFromEnv()
transportClient := transport.NewClient(nil)
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
// Market client here is only a fallback for Detail API calls inside
// DiscoverAllRuntime; the primary server-list fetch below goes
// through fetchRegistryServers so edition DiscoveryURL wins.
service := discovery.NewService(
market.NewClient(DiscoveryBaseURL(), nil),
transportClient,
store,
)
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
if err != nil {
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
}
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
selected := selectServersForProduct(servers, product)
if strings.TrimSpace(product) != "" && len(selected) == 0 {
return apperrors.NewValidation(fmt.Sprintf("no market server matched product %q", product))
}
if len(selected) == 0 {
selected = servers
}
if err := clearRuntimeCacheForServers(store, service.CachePartition(), selected); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear cache before refresh: %v", err))
}
refreshable := filterRefreshableServers(selected)
_, failures := service.DiscoverAllRuntime(cmd.Context(), refreshable)
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存刷新完成:已刷新 %d 个服务,失败 %d 个\n缓存目录: %s\n",
len(refreshable),
len(failures),
store.Root,
)
return err
},
}
refreshCmd.Flags().String("product", "", "Refresh only the selected canonical product")
_ = refreshCmd.Flags().MarkHidden("product")
cleanCmd := &cobra.Command{
Use: "clean",
Short: "清理缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
staleOnly, err := cmd.Flags().GetBool("stale")
if err != nil {
return apperrors.NewInternal("failed to read cache clean stale flag")
}
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache clean product flag")
}
store := cacheStoreFromEnv()
removed, err := cleanCacheFiles(store.Root, product, staleOnly)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clean cache: %v", err))
}
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存清理完成:已删除 %d 个文件\n",
removed,
)
return err
},
}
cleanCmd.Flags().Bool("stale", false, "Only remove stale cache entries")
cleanCmd.Flags().String("product", "", "Clean only the selected canonical product")
cleanCmd.Hidden = true
cacheCmd.AddCommand(statusCmd, refreshCmd, cleanCmd)
return cacheCmd
}
func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
@@ -678,7 +504,7 @@ func newVersionCommand() *cobra.Command {
gc := GitCommit()
goVer := "1.24+"
arch := "MCP Dynamic Aggregation"
arch := "MCP Static Endpoint Mode"
if wantJSON {
payload := map[string]any{
@@ -716,166 +542,19 @@ func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
}
func newGenerateSkillsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate-skills",
Short: "Generate agent skills from canonical metadata",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
source, err := cmd.Flags().GetString("source")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills source flag")
}
outputRoot, err := cmd.Flags().GetString("output-root")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills output-root flag")
}
withDocs, err := cmd.Flags().GetBool("with-docs")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills with-docs flag")
}
fixture, err := cmd.Flags().GetString("fixture")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills fixture flag")
}
snapshot, err := cmd.Flags().GetString("snapshot")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills snapshot flag")
}
catalogPath := fixture
if strings.EqualFold(strings.TrimSpace(source), string(generator.CatalogSourceSnapshot)) {
catalogPath = snapshot
}
for flagName, raw := range map[string]string{
"--output-root": outputRoot,
"--fixture": fixture,
"--snapshot": snapshot,
} {
if err := validateOptionalPath(flagName, raw); err != nil {
return err
}
}
catalog, err := generator.LoadCatalogWithSource(cmd.Context(), source, catalogPath)
if err != nil {
return apperrors.NewDiscovery(fmt.Sprintf("failed to load canonical catalog: %v", err))
}
artifacts, err := generator.Generate(catalog)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to generate skill artifacts: %v", err))
}
if withDocs {
if err := generator.WriteArtifacts(outputRoot, artifacts); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated artifacts: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d artifact(s) in %s\n", len(artifacts), outputRoot)
return err
}
targets := make([]generator.Artifact, 0)
for _, artifact := range artifacts {
if !strings.HasPrefix(artifact.Path, "skills/") {
continue
}
targets = append(targets, artifact)
}
if len(targets) == 0 {
return apperrors.NewInternal("no generated skill artifacts were produced")
}
if err := generator.WriteArtifacts(outputRoot, targets); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated skills: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d skill artifact(s) in %s\n", len(targets), outputRoot)
return err
},
}
cmd.Flags().String("output-root", ".", "Directory root for generated artifacts")
cmd.Flags().Bool("with-docs", true, "Write docs/schema artifacts in addition to skills")
cmd.Flags().String("source", string(generator.CatalogSourceFixture), "Catalog source for skill generation: fixture, env, or snapshot")
cmd.Flags().String("fixture", "", "Optional path to a catalog fixture; used by --source fixture")
cmd.Flags().String("snapshot", "", "Optional path to a catalog snapshot; used by --source snapshot")
return cmd
}
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
// catalog-driven canonical build can be simulated without crafting a
// poisoned on-disk cache.
// buildMCPCommandFn is a test seam for newMCPCommand.
var buildMCPCommandFn = cli.NewMCPCommand
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
// cache when the build panics and degrading to an inert stub if that also
// fails.
//
// Why this guard exists: the canonical tree is assembled from cached catalog
// data BEFORE the legacy command build and before Cobra dispatches anything,
// so a panic here (e.g. a tool schema property named after the reserved
// --params flag, as cached during the 1.0.32 incident) used to abort every
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
// fresh fetch, then degrade with a `dws cache refresh` hint.
// newMCPCommand builds the `dws mcp` command tree.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
return cmd
}
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
if qErr != nil {
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
}
if quarantined != "" {
fmt.Fprintf(os.Stderr,
"Warning: building canonical 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)
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
if panicked == nil {
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
return cmd
}
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
}
fmt.Fprintf(os.Stderr,
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
stub := &cobra.Command{
Use: "mcp",
Short: "Canonical MCP-derived CLI surface (unavailable)",
Hidden: true,
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return buildErr
},
}
return stub
}
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
// panic into a return value so the caller can decide between self-heal and
// degradation.
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
cmd = nil
panicked = r
}
}()
return buildMCPCommandFn(ctx, loader, runner, engine), nil
return buildMCPCommandFn(ctx, loader, runner, engine)
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
// unless they correspond to a product discovered via dynamic server discovery
// or listed in the edition's VisibleProducts hook.
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
// unless they correspond to a static endpoint product or an edition-visible
// compatibility command.
// Public utility commands are always kept visible; explicitly hidden commands
// stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
allowedProducts := resolveVisibleProducts()
staticCommands := map[string]bool{
@@ -883,6 +562,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"api": true,
"cache": true,
"config": true,
"dev": true,
"doctor": true,
"completion": true,
"skill": true,
@@ -893,6 +573,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"recovery": true,
"schema": true,
"mcp": true,
"upgrade": true,
}
for _, cmd := range root.Commands() {
name := cmd.Name()
@@ -987,11 +668,6 @@ func deduplicateCommands(root *cobra.Command) {
}
}
func cacheStoreFromEnv() *cache.Store {
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
return cache.NewStore(cacheDir)
}
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
// servers. Timeouts only apply to the *first* discovery for a given
// plugin/server; subsequent startups take the warm cache path and bypass
@@ -1078,168 +754,6 @@ func validateOptionalPath(flagName, path string) error {
return nil
}
func cacheDirectoryStats(root string) (int, int64, error) {
if strings.TrimSpace(root) == "" {
return 0, 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, 0, nil
}
return 0, 0, err
}
files := 0
var bytes int64
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
files++
bytes += info.Size()
return nil
})
return files, bytes, err
}
func selectServersForProduct(servers []market.ServerDescriptor, product string) []market.ServerDescriptor {
product = strings.TrimSpace(strings.ToLower(product))
if product == "" {
return servers
}
selected := make([]market.ServerDescriptor, 0)
for _, server := range servers {
candidates := []string{
strings.ToLower(strings.TrimSpace(server.DisplayName)),
strings.ToLower(strings.TrimSpace(server.Key)),
strings.ToLower(strings.TrimSpace(path.Base(server.Endpoint))),
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if candidate == product || strings.Contains(candidate, product) {
selected = append(selected, server)
break
}
}
}
return selected
}
func filterRefreshableServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, server := range servers {
if server.CLI.Skip {
continue
}
filtered = append(filtered, server)
}
return filtered
}
func clearRuntimeCacheForServers(store *cache.Store, partition string, servers []market.ServerDescriptor) error {
for _, server := range servers {
for _, cacheKey := range cacheKeysForServer(server) {
if err := store.DeleteTools(partition, cacheKey); err != nil {
return err
}
}
for _, cacheKey := range detailCacheKeysForServer(server) {
if err := store.DeleteDetail(partition, cacheKey); err != nil {
return err
}
}
}
return nil
}
func cacheKeysForServer(server market.ServerDescriptor) []string {
seen := make(map[string]struct{}, 2)
keys := make([]string, 0, 2)
for _, candidate := range []string{
strings.TrimSpace(server.Key),
strings.TrimSpace(server.CLI.ID),
} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
keys = append(keys, candidate)
}
return keys
}
func detailCacheKeysForServer(server market.ServerDescriptor) []string {
key := strings.TrimSpace(server.Key)
if key != "" {
return []string{key}
}
id := strings.TrimSpace(server.CLI.ID)
if id != "" {
return []string{id}
}
return nil
}
func cleanCacheFiles(root, product string, staleOnly bool) (int, error) {
if strings.TrimSpace(root) == "" {
return 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
staleCutoff := time.Now().UTC().Add(-cache.ToolsTTL)
product = strings.TrimSpace(strings.ToLower(product))
removed := 0
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
normalizedPath := strings.ToLower(filepath.ToSlash(entryPath))
if product != "" && !strings.Contains(normalizedPath, product) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if staleOnly && info.ModTime().After(staleCutoff) {
return nil
}
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
return err
}
removed++
return nil
})
if err != nil {
return 0, err
}
return removed, nil
}
// fileLogger holds the package-level file logger for diagnostics.
// It is initialized by configureLogLevel and closed by CloseFileLogger.
var fileLogger *logging.FileLogger
@@ -1328,7 +842,7 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
// Collect all server descriptors and register auth first (fast, no I/O).
type pluginServer struct {
plugin *plugin.Plugin
srv market.ServerDescriptor
srv mcptypes.ServerDescriptor
}
var httpServers []pluginServer
@@ -1368,38 +882,21 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
}
}
// Share one cache.Store across all discovery goroutines. Each goroutine
// writes to a distinct serverKey path ("tools/<plugin>_<server>.json") with
// atomic tmp+rename, so concurrent writes to different keys never collide
// on the filesystem. Global in-process registries (AppendDynamicServer,
// RegisterStdioClient) carry their own sync.Mutex; see direct_runtime.go
// and stdio_registry.go.
sharedStore := cacheStoreFromEnv()
coldTimeouts := resolvePluginColdTimeouts()
// Phase A: stdio overlay-first registration (synchronous, no I/O).
// Plugins whose overlay.json declares ToolOverrides register their full
// command tree up-front from manifest metadata alone — no subprocess
// handshake required. This fixes the "discovery fails → no commands
// ever appear" lock-out and keeps `dws --help` reliable even when the
// underlying MCP server is temporarily unavailable.
// Plugins whose overlay.json declares ToolOverrides register their
// server descriptor up-front from manifest metadata alone.
var legacyStdioEntries []stdioEntry
for _, e := range stdioEntries {
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
_, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner)
if !ok {
legacyStdioEntries = append(legacyStdioEntries, e)
continue
}
pluginCmds = append(pluginCmds, cmds...)
}
// Phase B: fan out discovery in parallel.
// - HTTP plugins: same behaviour as before (discovery-first).
// - stdio overlay-first plugins: async cache refresh only; their
// commands are already registered. Failures are non-fatal and do
// NOT poison the warm-cache with a null-tools snapshot.
// - stdio legacy plugins (overlay without toolOverrides): preserve
// the old discovery-first path for backwards compatibility.
httpResults := make([][]*cobra.Command, len(httpServers))
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
var wg sync.WaitGroup
@@ -1407,26 +904,15 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
wg.Add(1)
go func(idx int, ps pluginServer) {
defer wg.Done()
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, coldTimeouts)
}(i, ps)
}
// overlay-first stdio: async refresh (no command building here).
for _, e := range stdioEntries {
if !hasOverlayToolOverrides(e.plugin, e.sc) {
continue
}
wg.Add(1)
go func(e stdioEntry) {
defer wg.Done()
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
}(e)
}
// legacy stdio: discovery-first (commands depend on tool list).
for i, e := range legacyStdioEntries {
wg.Add(1)
go func(idx int, e stdioEntry) {
defer wg.Done()
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, coldTimeouts)
}(i, e)
}
wg.Wait()
@@ -1468,62 +954,25 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
return pluginCmds
}
// pluginCacheKey derives the cache key used to persist a plugin MCP server's
// tool list. Prefixed with "plugin:" so entries are namespaced apart from the
// Market-derived cache, and visible distinctly via `dws cache status`.
func pluginCacheKey(pluginName, serverKey string) string {
return "plugin:" + pluginName + ":" + serverKey
}
// registerHTTPServer discovers tools from a streamable-http MCP server and
// builds CLI commands. Used for plugin-owned HTTP servers that provide CLI metadata.
//
// Startup-latency strategy (issue #119):
// - Warm cache: build commands from the persisted tools snapshot
// synchronously — no network I/O. `dws --help` returns in ms even when
// the plugin endpoint is unreachable.
// - Cold cache: synchronous discovery (Initialize + ListTools) with a tight
// timeout. The outcome — success or failure — is persisted so the next
// invocation hits the warm path. Refresh on demand via `dws cache clean`
// / `dws cache refresh`; the cache TTL (7d) otherwise expires naturally.
//
// When the server descriptor carries AuthHeaders (from plugin.json "headers"),
// a dedicated transport.Client is created with the plugin's Bearer token and
// trusted domains so that third-party MCP servers requiring independent
// authentication (e.g. Alibaba Cloud Bailian) can be discovered at startup.
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, srv.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: http server served from cache",
"plugin", p.Manifest.Name, "server", srv.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildHTTPCommandsFromTools(srv, snapshot.Tools, runner)
}
// Cold cache: synchronous discovery. Persist the outcome even on failure
// (empty tools == negative cache) so the next invocation takes the fast
// path regardless of endpoint health.
// registers the server. Dynamic command building has been removed; this now
// simply registers the server descriptor for direct runtime dispatch.
func registerHTTPServer(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
tools := discoverHTTPTools(p, srv, tc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildHTTPCommandsFromTools(srv, tools, runner)
}
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
// for an HTTP MCP server and returns the discovered tools. Returns nil on
// any transport/protocol error; errors are logged at Debug level.
func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
func discoverHTTPTools(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
// Cold-path budget. An unreachable endpoint will burn the full window
// via the TCP dial timeout; a healthy localhost/third-party endpoint
// typically responds in <200 ms. Third-party servers with auth get a
// slightly larger window to accommodate TLS + auth RTT. Operators with
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
// The outcome is persisted as a negative cache so subsequent startups
// (80 ms warm) are unaffected. See issue #119.
// TODO(remove-discovery): plugin discovery currently has no warm cache, so
// unreachable endpoints still pay this timeout during command startup.
timeout := timeouts.httpNoAuth
if len(srv.AuthHeaders) > 0 {
timeout = timeouts.httpAuth
@@ -1551,67 +1000,20 @@ func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transp
return toolsResult.Tools
}
// buildHTTPCommandsFromTools converts a tool list into Cobra commands via
// the BuildDynamicCommands path. Returns nil for an empty tool list.
func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
return nil
}
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
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,
})
}
detailsByID[strings.TrimSpace(srv.CLI.ID)] = detailTools
// If the server has no ToolOverrides (e.g. third-party MCP servers that
// only declare cli.id and cli.command), auto-generate one override per
// discovered tool so BuildDynamicCommands can create leaf commands.
if len(srv.CLI.ToolOverrides) == 0 {
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(tools))
for _, tool := range tools {
srv.CLI.ToolOverrides[tool.Name] = market.CLIToolOverride{
CLIName: deriveToolCLIName(tool.Name),
}
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
// "maps.search_poi") into a kebab-case CLI command name ("search" or
// "search-poi"). It strips common prefixes and replaces underscores/dots
// with hyphens.
func deriveToolCLIName(toolName string) string {
// Use the last segment after "." as the base name.
if idx := strings.LastIndex(toolName, "."); idx >= 0 {
toolName = toolName[idx+1:]
}
// Replace underscores with hyphens for kebab-case.
return strings.ReplaceAll(toolName, "_", "-")
// buildHTTPCommandsFromTools registers the server for direct runtime
// dispatch. Dynamic command tree building has been removed.
func buildHTTPCommandsFromTools(srv mcptypes.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
_ = srv
_ = tools
_ = runner
// Dynamic command building from compat.BuildDynamicCommands has been removed.
return nil
}
// buildPluginAuthClient creates a transport.Client copy with the plugin's
// Bearer token and trusted domains injected. This allows third-party MCP
// servers that require independent authentication to be discovered at startup.
func buildPluginAuthClient(base *transport.Client, srv market.ServerDescriptor) *transport.Client {
func buildPluginAuthClient(base *transport.Client, srv mcptypes.ServerDescriptor) *transport.Client {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1638,7 +1040,7 @@ func buildPluginAuthClient(base *transport.Client, srv market.ServerDescriptor)
// a server descriptor's AuthHeaders and registers them in the global
// PluginAuth registry. The runner uses this registry at execution time
// to inject the correct Bearer token for third-party MCP servers.
func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1668,30 +1070,10 @@ func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
})
}
// registerStdioServer initializes a stdio MCP server, discovers its tools
// via ListTools, builds CLI commands, and registers the StdioClient for
// runtime dispatch. Returns generated cobra commands.
//
// Warm-cache fast path (issue #119): when a tools snapshot is already cached
// for this plugin/server, skip the Initialize + ListTools RPC round-trip and
// rebuild commands directly from the snapshot. Cold cache falls back to
// synchronous discovery with a 4s cap and persists the outcome.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: stdio server served from cache",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildStdioCommands(p, sc, snapshot.Tools, runner)
}
// registerStdioServer initializes a stdio MCP server, discovers its tools,
// and registers the StdioClient for runtime dispatch.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
tools := discoverStdioTools(p, sc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildStdioCommands(p, sc, tools, runner)
}
@@ -1734,14 +1116,8 @@ func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts
return toolsResult.Tools
}
// buildStdioCommands constructs Cobra commands from a tool list and
// registers the runtime dispatch state (StdioClient + dynamic server).
// Returns nil for an empty tool list.
//
// This is the legacy discovery-first path, used only for stdio plugins whose
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
// register commands up-front via registerStdioServerFromOverlay, bypassing
// this function entirely (see plugin_stdio_overlay.go).
// buildStdioCommands registers the stdio client and server descriptor
// for direct runtime dispatch. Dynamic command tree building has been removed.
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
slog.Debug("plugin: stdio server has no tools",
@@ -1751,21 +1127,7 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
overlay := resolveStdioOverlay(p, sc)
// Auto-generate ToolOverrides from discovered tools when not provided
// by the manifest/overlay (legacy discovery-first path).
if len(overlay.ToolOverrides) == 0 {
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
if len(overlay.Prefixes) == 0 {
overlay.Prefixes = []string{overlay.ID}
}
for _, tool := range tools {
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
IsSensitive: tool.Sensitive,
}
}
}
descriptor := market.ServerDescriptor{
descriptor := mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -1778,16 +1140,12 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(tools), "commands", len(cmds))
"tools", len(tools))
return cmds
_ = runner
return nil
}
// newPipelineEngine creates and configures the pipeline engine with
-254
View File
@@ -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/cedar":
_ = 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/cedar":
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)
}
}
-440
View File
@@ -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", "profile", "version", "Global Flags:", "--profile"} {
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)
}
}
+366
View File
@@ -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
}
+8 -4
View File
@@ -412,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 后重试",
}
}
@@ -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
+9 -4
View File
@@ -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 {
+82
View File
@@ -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"})
+16 -4
View File
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
dws skill setup --mode mono --yes # 非交互装 mono
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
DisableAutoGenTag: true,
RunE: runSkillSetup,
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
fmt.Fprintln(out, "将安装到:")
for _, d := range dests {
fmt.Fprintf(out, " - %s\n", d)
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
}
return nil
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
@@ -203,8 +216,7 @@ func normalizeMultiSkillName(name string) string {
return multiSkillPrefix + n
}
// filterMultiSkillNames narrows `all` by include / exclude lists.
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
// filterMultiSkillNames narrows `all` by include / exclude lists:
//
// - include + exclude are mutually exclusive (both → error)
// - names accept short or full form; normalized before matching
@@ -493,7 +505,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
+13
View File
@@ -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)
}
}
+1 -2
View File
@@ -445,8 +445,7 @@ func TestFilterMultiSkillNames(t *testing.T) {
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
// touch already-installed dingtalk-* siblings (additive semantics, matches
// lark-cli `npx skills add -s lark-calendar`).
// touch already-installed dingtalk-* siblings (additive semantics).
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
+6
View File
@@ -35,6 +35,11 @@ import (
// Setting keychain.StorageDirEnv here forces every keychain read/write in
// this binary into a per-process tempdir, eliminating that contamination
// without touching production code.
//
// PAT authorization tests also exercise code paths that normally open the
// system browser. Keep the package-wide default opener inert so running the
// test binary never launches a page on the developer's machine; tests that
// need to assert the URL can still replace openBrowserFunc locally.
func TestMain(m *testing.M) {
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
@@ -44,6 +49,7 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
openBrowserFunc = func(string) error { return nil }
code := m.Run()
_ = os.RemoveAll(tmpDir)
os.Exit(code)
+14
View File
@@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func (a *toolCallerAdapter) Fields() string {
if a.flags != nil {
return a.flags.Fields
}
return ""
}
func (a *toolCallerAdapter) JQ() string {
if a.flags != nil {
return a.flags.JQ
}
return ""
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
+63 -31
View File
@@ -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 {
+28 -1
View File
@@ -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
-151
View File
@@ -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())
}
}
+157
View File
@@ -0,0 +1,157 @@
// 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 (
"errors"
"fmt"
"os"
)
// CredentialSource identifies where a particular credential field
// (ClientID or ClientSecret) was loaded from. It is exposed in
// `dws event status` and the HelloAck IPC frame so users can verify which
// credential channel is actually in use — important because env vars,
// keychain, and config file can all coexist and silently override each
// other (see plan §1 决策 "凭证来源拆字段").
type CredentialSource string
const (
CredentialSourceUnknown CredentialSource = "unknown"
CredentialSourceEnv CredentialSource = "env"
CredentialSourceAppConfig CredentialSource = "app_config" // value pulled from app config (plain or SecretRef metadata)
CredentialSourceKeychain CredentialSource = "keychain" // SecretRef resolved through OS keychain
CredentialSourcePlainConfig CredentialSource = "plain_config" // SecretInput stored as plaintext in config file (insecure but supported)
)
// Strict resolver error sentinels. Use errors.Is to distinguish failure
// modes; see plan §8 strict resolver decision (4 classes).
var (
// ErrAppConfigMissing — no app config file on disk AND no env-var
// credentials present. Prompt the user to either `dws config init` or
// set DWS_CLIENT_ID + DWS_CLIENT_SECRET.
ErrAppConfigMissing = errors.New("app config missing: run `dws config init` or set DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
// ErrClientIDEmpty — neither env nor config supplies a non-empty ClientID.
ErrClientIDEmpty = errors.New("ClientID is empty")
// ErrClientSecretEmpty — there's a ClientID but ClientSecret resolved to "".
ErrClientSecretEmpty = errors.New("ClientSecret is empty")
// ErrSecretResolve — the secret-resolution backend (keychain) failed
// unrecoverably. Typically headless Linux without gnome-keyring, locked
// macOS keychain, or CI sandboxes. Suggest the env-var fallback.
ErrSecretResolve = errors.New("ClientSecret resolution failed (keychain unavailable?); try DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
)
// Env var names used by the env fallback channel. Must be set as a pair —
// any single-variable configuration is rejected so users cannot accidentally
// "set the env half-way" and silently fall back to keychain.
const (
EnvClientID = "DWS_CLIENT_ID"
EnvClientSecret = "DWS_CLIENT_SECRET"
)
// ResolveAppCredentialsStrict is the credentials channel used by the event
// subsystem (and by future commands that need fine-grained failure
// reporting). It distinguishes 4 failure classes and reports the source of
// each successfully-resolved field separately.
//
// Resolution order:
// 1. Env var override: if BOTH DWS_CLIENT_ID and DWS_CLIENT_SECRET are
// set non-empty, use them as a pair and skip keychain/config entirely.
// Single-variable configuration is detected and reported via the
// EnvHalfSet flag in the warning channel (callers MAY log a warning).
// 2. App config from disk:
// - ClientID from cfg.ClientID
// - ClientSecret from ResolveSecret(cfg.ClientSecret):
// - SecretInput.IsPlain() → CredentialSourcePlainConfig
// - SecretRef → CredentialSourceKeychain (or whatever Ref.Source says)
//
// Empty returns: clientID and secret may be empty when err is non-nil;
// callers must NOT use them in that case.
func ResolveAppCredentialsStrict(configDir string) (
clientID, secret string,
clientIDSource, secretSource CredentialSource,
err error,
) {
// Step 1: env var fallback (atomic pair)
envID := os.Getenv(EnvClientID)
envSecret := os.Getenv(EnvClientSecret)
if envID != "" && envSecret != "" {
return envID, envSecret, CredentialSourceEnv, CredentialSourceEnv, nil
}
// Note: if only one of the two is set we explicitly do NOT use it.
// The half-set warning is surfaced via EnvHalfSet() so the CLI can
// stderr-warn the user during preflight.
// Step 2: app config from disk
cfg, loadErr := LoadAppConfig(configDir)
if loadErr != nil {
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
fmt.Errorf("load app config: %w", loadErr)
}
if cfg == nil {
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrAppConfigMissing
}
if cfg.ClientID == "" {
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrClientIDEmpty
}
clientID = cfg.ClientID
clientIDSource = CredentialSourceAppConfig
// Resolve secret. Source depends on the SecretInput shape:
// - IsPlain (no Ref) → it's stored as plaintext in the config file
// - has Ref → it's a SecretRef pointing at keychain/file
wasPlain := cfg.ClientSecret.IsPlain()
resolved, resolveErr := ResolveSecret(cfg.ClientSecret)
if resolveErr != nil {
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
fmt.Errorf("%w: %v", ErrSecretResolve, resolveErr)
}
if resolved == "" {
return "", "", clientIDSource, CredentialSourceUnknown, ErrClientSecretEmpty
}
secret = resolved
if wasPlain {
secretSource = CredentialSourcePlainConfig
} else {
// For SecretRef we map Source verbatim (keychain / file / future)
switch cfg.ClientSecret.Ref.Source {
case "keychain":
secretSource = CredentialSourceKeychain
default:
// File-backed secrets share the "plain_config" category from
// the consumer's perspective: stored as readable bytes outside
// keychain. Status output renders them as "plain_config" so
// users see "secret is not in keychain".
secretSource = CredentialSourcePlainConfig
}
}
return clientID, secret, clientIDSource, secretSource, nil
}
// EnvHalfSet reports whether exactly one of (DWS_CLIENT_ID, DWS_CLIENT_SECRET)
// is set. Used by CLI preflight to emit a clear stderr warning of the form:
//
// WARN: DWS_CLIENT_ID is set but DWS_CLIENT_SECRET is not — env fallback
// disabled; using keychain/app config. Set both or unset both to
// avoid this warning.
//
// The strict resolver itself does NOT log; logging is the caller's job.
func EnvHalfSet() bool {
id := os.Getenv(EnvClientID) != ""
secret := os.Getenv(EnvClientSecret) != ""
return id != secret
}
+165
View File
@@ -0,0 +1,165 @@
// 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 (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// resetStrictResolverState clears caches the strict resolver shares with
// the existing legacy resolver. Tests must call this between scenarios
// because GetCachedAppConfig and the resolved-credential cache outlive
// individual t.TempDir setups.
func resetStrictResolverState(t *testing.T) {
t.Helper()
cachedAppConfigMu.Lock()
cachedAppConfig = nil
cachedAppConfigMu.Unlock()
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.Unlock()
}
// writeAppConfig drops a config JSON into dir. clientSecret == "" produces
// the legacy "no SecretInput field" shape (treated as empty).
func writeAppConfig(t *testing.T, dir, clientID, clientSecret string) {
t.Helper()
cfg := AppConfig{
ClientID: clientID,
CreatedAt: time.Now(),
}
if clientSecret != "" {
cfg.ClientSecret = PlainSecret(clientSecret)
}
path := GetAppConfigPath(dir)
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
}
func unsetEnv(t *testing.T) {
t.Helper()
t.Setenv(EnvClientID, "")
t.Setenv(EnvClientSecret, "")
_ = os.Unsetenv(EnvClientID)
_ = os.Unsetenv(EnvClientSecret)
}
func TestResolveStrict_AppConfigMissing(t *testing.T) {
resetStrictResolverState(t)
unsetEnv(t)
dir := t.TempDir()
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
if !errors.Is(err, ErrAppConfigMissing) {
t.Fatalf("err = %v, want ErrAppConfigMissing", err)
}
}
func TestResolveStrict_ClientIDEmpty(t *testing.T) {
resetStrictResolverState(t)
unsetEnv(t)
dir := t.TempDir()
writeAppConfig(t, dir, "", "some-secret")
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
if !errors.Is(err, ErrClientIDEmpty) {
t.Fatalf("err = %v, want ErrClientIDEmpty", err)
}
}
func TestResolveStrict_ClientSecretEmpty(t *testing.T) {
resetStrictResolverState(t)
unsetEnv(t)
dir := t.TempDir()
writeAppConfig(t, dir, "ding_abc", "")
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
if !errors.Is(err, ErrClientSecretEmpty) {
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
}
}
func TestResolveStrict_PlainConfigSuccess(t *testing.T) {
resetStrictResolverState(t)
unsetEnv(t)
dir := t.TempDir()
writeAppConfig(t, dir, "ding_abc", "supersecret123")
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if id != "ding_abc" {
t.Errorf("id = %q", id)
}
if secret != "supersecret123" {
t.Errorf("secret = %q", secret)
}
if idSrc != CredentialSourceAppConfig {
t.Errorf("idSrc = %s, want app_config", idSrc)
}
if secretSrc != CredentialSourcePlainConfig {
t.Errorf("secretSrc = %s, want plain_config (PlainSecret was used)", secretSrc)
}
}
func TestResolveStrict_SecretRefFileSuccess(t *testing.T) {
resetStrictResolverState(t)
unsetEnv(t)
dir := t.TempDir()
// Write secret file
secretPath := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("via-file-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
// Write app config with file SecretRef
cfg := AppConfig{
ClientID: "ding_abc",
ClientSecret: SecretInput{
Ref: &SecretRef{Source: "file", ID: secretPath},
},
CreatedAt: time.Now(),
}
b, _ := json.MarshalIndent(cfg, "", " ")
if err := os.WriteFile(GetAppConfigPath(dir), b, 0o600); err != nil {
t.Fatal(err)
}
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if id != "ding_abc" || secret != "via-file-secret" {
t.Errorf("id/secret = %q/%q", id, secret)
}
if idSrc != CredentialSourceAppConfig {
t.Errorf("idSrc = %s", idSrc)
}
// File-backed secrets are reported as plain_config (not in keychain).
if secretSrc != CredentialSourcePlainConfig {
t.Errorf("secretSrc = %s, want plain_config for file-backed secret", secretSrc)
}
}
-26
View File
@@ -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
}
-393
View File
@@ -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
View File
@@ -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)
}
}
-387
View File
@@ -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
View File
@@ -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, &degraded) {
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
-47
View File
@@ -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
View File
@@ -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
-164
View File
@@ -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
}
-151
View File
@@ -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)
}
}
}
-192
View File
@@ -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 compat
import (
"encoding/json"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
// carry; the backend rejects partial items with an opaque error, so the CLI
// validates them up front (mirrors wukong's attendance.go).
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
var attendanceApproveTypes = map[string]bool{
"overtime": true, "trip": true, "travel": true, "business_trip": true,
"business-trip": true, "out": true, "leave": true, "patch": true,
"repair_check": true, "repair-check": true,
}
// installAttendanceHook wires attendance-specific PreRunE validators that
// mirror wukong's client-side checks (inner-JSON required fields, group type,
// FIXED conditional requirements, group-update no-op). No-op for other
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
return
}
var validate func(*cobra.Command) error
switch toolName {
case "generateTurnSchedule":
validate = validateAttendanceScheduleImport
case "create_class_setting":
validate = validateAttendanceClassCreate
case "create_group_setting":
validate = validateAttendanceGroupCreate
case "update_group_setting":
validate = validateAttendanceGroupUpdate
case "update_group_member":
validate = validateAttendanceUpdateMembers
case "save_self_setting":
validate = validateAttendanceSelfSettingSave
case "query_at_approve_template":
validate = validateAttendanceApproveTemplates
default:
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 validate(c)
}
}
func attFlagString(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if cmd.Flags().Lookup(n) == nil {
continue
}
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
raw := attFlagString(cmd, "scheduleVOS", "schedules")
if raw == "" {
return nil // empty is owned by the required-flag check
}
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil // malformed JSON is owned by a separate check
}
if len(items) == 0 {
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
}
for _, item := range items {
for _, f := range attendanceScheduleInnerRequired {
if _, ok := item[f]; !ok {
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
}
}
}
return nil
}
func validateAttendanceClassCreate(cmd *cobra.Command) error {
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
if raw == "" {
return nil
}
var vo map[string]any
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
return nil
}
if _, ok := vo["sections"]; !ok {
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
}
return nil
}
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceGroupTypes[typ] {
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
}
if typ == "FIXED" {
var vo map[string]any
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
_ = json.Unmarshal([]byte(raw), &vo)
}
if vo == nil {
vo = map[string]any{}
}
if _, ok := vo["workDayClassList"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
}
if _, ok := vo["defaultClassId"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
}
}
return nil
}
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
return apperrors.NewValidation("--enable-outside-check must be true or false")
}
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
}
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
}
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
hasField := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
// control / identity flags, not setting fields
default:
hasField = true
}
})
if !hasField {
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
}
return nil
}
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceApproveTypes[typ] {
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
}
return nil
}
-123
View File
@@ -1,123 +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 (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
// be supplied as a complete set (the MCP backend does not merge partial
// recurrence, so a partial update would silently overwrite the rule). Mirrors
// wukong's calendar.go event create/update validation.
var calendarRecurrenceTools = map[string]bool{
"create_calendar_event": true,
"update_calendar_event": true,
}
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
// of them requires the core structural fields to be present.
var calendarRecurrenceFlags = []string{
"recurrence-type", "recurrence-interval", "recurrence-range-type",
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
"recurrence-first-day-of-week",
}
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
// calendar tools without extra client-side checks. The hook chain preserves the
// PreRunE that NewDirectCommand already installed by invoking it first.
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
return
}
if !calendarRecurrenceTools[toolName] {
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 validateCalendarRecurrence(c)
}
}
// validateCalendarRecurrence refuses a partial recurrence structure. If any
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
// present, and weekly / relativeMonthly patterns require days-of-week. Error
// wording carries the kebab flag names so the messages match wukong and the
// auto-test substring assertions (days-of-week / recurrence-type).
func validateCalendarRecurrence(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
used := false
for _, f := range calendarRecurrenceFlags {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
used = true
break
}
}
if !used {
return nil
}
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
if recType == "" {
return apperrors.NewValidation(
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
"MCP 不合并部分字段)")
}
if !calendarFlagSet(cmd, "recurrence-interval") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
}
if !calendarFlagSet(cmd, "recurrence-range-type") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
}
if recType == "weekly" || recType == "relativeMonthly" {
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
return apperrors.NewValidation(
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
}
}
return nil
}
func calendarFlagString(cmd *cobra.Command, name string) string {
if cmd.Flags().Lookup(name) == nil {
return ""
}
v, _ := cmd.Flags().GetString(name)
return v
}
// calendarFlagSet reports whether a flag was explicitly provided by the user,
// tolerating both string and int (--recurrence-interval) flag kinds.
func calendarFlagSet(cmd *cobra.Command, name string) bool {
fl := cmd.Flags().Lookup(name)
if fl == nil {
return false
}
return cmd.Flags().Changed(name)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-55
View File
@@ -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
}
-367
View File
@@ -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 ""
}
-424
View File
@@ -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)
}
}
-107
View File
@@ -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)
}
}
}
}
-257
View File
@@ -1,257 +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/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
-441
View File
@@ -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
-135
View File
@@ -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, 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, 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")
}
}
-314
View File
@@ -1,314 +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 (
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
func TestApplyBindings_PositionalWithFlagAliases(t *testing.T) {
t.Parallel()
// envelope shape: { keyword: { alias: "query", aliases: ["keyword"], positional: true } }
bindings := []FlagBinding{
{
FlagName: "query",
Aliases: []string{"keyword"},
Property: "keyword",
Kind: ValueString,
Usage: "搜索关键词",
Required: true,
Positional: true,
PositionalIndex: 0,
},
}
cmd := &cobra.Command{Use: "search"}
ApplyBindings(cmd, bindings)
primary := cmd.Flags().Lookup("query")
if primary == nil {
t.Fatal("--query flag should be registered for dual-mode positional")
}
if primary.Hidden {
t.Fatal("--query flag should be visible")
}
hidden := cmd.Flags().Lookup("keyword")
if hidden == nil {
t.Fatal("--keyword alias flag should be registered")
}
if !hidden.Hidden {
t.Fatal("--keyword alias flag should be hidden")
}
// --query should NOT be marked required at cobra level — that would
// break flag-only invocation when arity is relaxed.
if _, ok := primary.Annotations[cobra.BashCompOneRequiredFlag]; ok {
t.Fatal("--query should not be MarkFlagRequired (validation happens in RunE)")
}
}
func TestApplyBindings_PurePositionalSkipsFlagRegistration(t *testing.T) {
t.Parallel()
// Pure positional (no Alias / no Aliases) → no flag should be registered;
// arity validator (set in NewDirectCommand) handles required-presence.
bindings := []FlagBinding{
{
Property: "text",
Kind: ValueString,
Required: true,
Positional: true,
PositionalIndex: 0,
},
}
cmd := &cobra.Command{Use: "send"}
ApplyBindings(cmd, bindings)
if f := cmd.Flags().Lookup("text"); f != nil {
t.Fatalf("pure positional should not register a flag, got %+v", f)
}
}
func TestCollectBindingsParsesTypedValuesAndAcceptsAliasFlags(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "dept-ids", Alias: "deptIds", Property: "deptIds", Kind: ValueFloatSlice},
{FlagName: "ratio", Property: "ratio", Kind: ValueFloat},
{FlagName: "enabled-flags", Property: "enabledFlags", Kind: ValueBoolSlice},
{FlagName: "base-id", Alias: "baseId", Property: "baseId", Kind: ValueString},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
if err := cmd.Flags().Set("deptIds", "1,2.5"); err != nil {
t.Fatalf("Set(deptIds) error = %v", err)
}
if err := cmd.Flags().Set("ratio", "1.25"); err != nil {
t.Fatalf("Set(ratio) error = %v", err)
}
if err := cmd.Flags().Set("enabled-flags", "true,false"); err != nil {
t.Fatalf("Set(enabled-flags) error = %v", err)
}
if err := cmd.Flags().Set("baseId", "B1"); err != nil {
t.Fatalf("Set(baseId) error = %v", err)
}
params, err := CollectBindings(cmd, bindings, nil)
if err != nil {
t.Fatalf("CollectBindings() error = %v", err)
}
if params["baseId"] != "B1" {
t.Fatalf("baseId = %#v, want B1", params["baseId"])
}
if params["ratio"] != 1.25 {
t.Fatalf("ratio = %#v, want 1.25", params["ratio"])
}
if want := []any{1.0, 2.5}; !reflect.DeepEqual(params["deptIds"], want) {
t.Fatalf("deptIds = %#v, want %#v", params["deptIds"], want)
}
if want := []any{true, false}; !reflect.DeepEqual(params["enabledFlags"], want) {
t.Fatalf("enabledFlags = %#v, want %#v", params["enabledFlags"], want)
}
aliasFlag := cmd.Flags().Lookup("baseId")
if aliasFlag == nil || !aliasFlag.Hidden {
t.Fatalf("baseId alias flag hidden = false, want true")
}
}
func TestHiddenAliasFlagsDoNotInflateLeafCount(t *testing.T) {
t.Parallel()
// overlay leaf: 2 primary + 2 hidden aliases = 4 total, but only 2 visible
overlay := &cobra.Command{Use: "list"}
ApplyBindings(overlay, []FlagBinding{
{FlagName: "start-time", Alias: "startTime", Property: "startTime", Kind: ValueString},
{FlagName: "end-time", Alias: "endTime", Property: "endTime", Kind: ValueString},
})
// curated compat leaf: 3 primary, 0 aliases = 3 visible
curated := &cobra.Command{Use: "list"}
ApplyBindings(curated, []FlagBinding{
{FlagName: "start", Property: "start", Kind: ValueString},
{FlagName: "end", Property: "end", Kind: ValueString},
{FlagName: "calendar-id", Property: "calendarId", Kind: ValueString},
})
overlayCount := cobracmd.LocalFlagCount(overlay)
curatedCount := cobracmd.LocalFlagCount(curated)
// overlay has 2 visible flags (start-time, end-time) + json + params = 4
// curated has 3 visible flags (start, end, calendar-id) + json + params = 5
if overlayCount >= curatedCount {
t.Fatalf("overlay visible flags (%d) >= curated visible flags (%d); hidden aliases should not be counted",
overlayCount, curatedCount)
}
// shouldReplaceCompatLeaf should NOT replace curated with overlay
if cobracmd.ShouldReplaceLeaf(curated, overlay) {
t.Fatal("cobracmd.ShouldReplaceLeaf(curated, overlay) = true; overlay should not displace curated")
}
}
func TestCollectBindingsParsesJSONFlagValue(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "fields", Property: "fields", Kind: ValueJSON},
{FlagName: "config", Property: "config", Kind: ValueJSON},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
if err := cmd.Flags().Set("fields", `[{"fieldName":"title","type":"text"}]`); err != nil {
t.Fatalf("Set(fields) error = %v", err)
}
if err := cmd.Flags().Set("config", `{"options":[{"name":"high"}]}`); err != nil {
t.Fatalf("Set(config) error = %v", err)
}
params, err := CollectBindings(cmd, bindings, nil)
if err != nil {
t.Fatalf("CollectBindings() error = %v", err)
}
fields, ok := params["fields"].([]any)
if !ok || len(fields) != 1 {
t.Fatalf("fields = %#v, want array of 1 element", params["fields"])
}
firstField, ok := fields[0].(map[string]any)
if !ok || firstField["fieldName"] != "title" {
t.Fatalf("fields[0] = %#v, want {fieldName:title, type:text}", fields[0])
}
config, ok := params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want map", params["config"])
}
options, ok := config["options"].([]any)
if !ok || len(options) != 1 {
t.Fatalf("config.options = %#v, want array of 1", config["options"])
}
}
func TestCollectSchemaFlagsPicksUpUnboundFlags(t *testing.T) {
t.Parallel()
// Simulate a plugin command with schema-generated flags but no bindings.
cmd := &cobra.Command{Use: "greet"}
cmd.Flags().String("name", "", "Name of person")
cmd.Flags().String("language", "en", "Language")
cmd.Flags().Int("count", 0, "Repeat count")
cmd.Flags().Bool("loud", false, "Loud mode")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
// User sets --name and --count but not --language
_ = cmd.Flags().Set("name", "Alice")
_ = cmd.Flags().Set("count", "3")
_ = cmd.Flags().Set("loud", "true")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
if params["name"] != "Alice" {
t.Errorf("name = %v, want Alice", params["name"])
}
if params["count"] != 3 {
t.Errorf("count = %v, want 3", params["count"])
}
if params["loud"] != true {
t.Errorf("loud = %v, want true", params["loud"])
}
// language was not set by user, should not appear
if _, exists := params["language"]; exists {
t.Errorf("language should not be in params (not set by user)")
}
// json/params are reserved, should not appear
if _, exists := params["json"]; exists {
t.Error("json should be skipped")
}
}
func TestCollectSchemaFlagsSkipsBoundFlags(t *testing.T) {
t.Parallel()
bindings := []FlagBinding{
{FlagName: "dept-id", Property: "deptId", Kind: ValueString},
}
cmd := &cobra.Command{Use: "test"}
ApplyBindings(cmd, bindings)
// Also add a schema-generated flag
cmd.Flags().String("title", "", "Title")
_ = cmd.Flags().Set("dept-id", "D001")
_ = cmd.Flags().Set("title", "Hello")
params := make(map[string]any)
collectSchemaFlags(cmd, bindings, params)
// dept-id is bound, should NOT be collected by collectSchemaFlags
if _, exists := params["dept_id"]; exists {
t.Error("dept-id should be skipped (already has binding)")
}
// title is unbound, should be collected
if params["title"] != "Hello" {
t.Errorf("title = %v, want Hello", params["title"])
}
}
func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("name", "", "Name")
cmd.Flags().Bool("debug", false, "Debug")
cmd.Flags().Bool("verbose", false, "Verbose")
cmd.Flags().Bool("dry-run", false, "Dry run")
cmd.Flags().String("format", "json", "Format")
cmd.Flags().String("profile", "", "Profile")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
_ = cmd.Flags().Set("name", "Bob")
_ = cmd.Flags().Set("debug", "true")
_ = cmd.Flags().Set("verbose", "true")
_ = cmd.Flags().Set("dry-run", "true")
_ = cmd.Flags().Set("format", "table")
_ = cmd.Flags().Set("profile", "corp_profile")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
if params["name"] != "Bob" {
t.Errorf("name = %v, want Bob", params["name"])
}
// Global flags should be skipped
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
if _, exists := params[skip]; exists {
t.Errorf("%s should be skipped (global flag)", skip)
}
}
}
-186
View File
@@ -1,186 +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.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
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 resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
-198
View File
@@ -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 compat
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
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 TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
-114
View File
@@ -1,114 +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.
// todo_hooks.go — CLI-side validators for the `todo` product. The envelope
// describes PersonalTodoCreateVO.parentId as a plain string flag (`--parent-id`)
// and the upstream MCP tool create_personal_sub_todo silently accepts any
// non-empty value: when a non-numeric string slips through, the server treats
// the missing numeric parent as "no parent" and creates an *orphan* root-level
// todo instead of failing. The auto-test
// todo/test_03_todo_create_sub.py::test_create_sub_todo_invalid_parent_id
// expects the CLI to reject the invalid value before it ever reaches MCP.
//
// The wukong reference implementation already does the same check inside its
// hand-written cobra RunE (see dws-wukong/wukong/products/todo.go ~line 90:
// strconv.ParseInt + CLIError with "父待办 ID 必须是纯数字, 当前值: ..."). The
// open-source CLI is envelope-driven, so we attach the equivalent guard as a
// PreRunE hook here. Empty parent-id is intentionally NOT validated here —
// envelope already marks it required, so cobra's MarkFlagRequired handles the
// missing case with the standard "required flag(s) ... not set" message.
package compat
import (
"fmt"
"strconv"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// todoToolsWithNumericParentId lists every todo toolName whose --parent-id
// must be coerced to a pure-numeric long. Today only create_personal_sub_todo
// needs this; if future tools (e.g. add_sub_todo) join, append here.
var todoToolsWithNumericParentId = map[string]bool{
"create_personal_sub_todo": true,
}
// installTodoHook wires todo-specific PreRunE validators onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-todo products and
// for todo 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 installTodoHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "todo" {
return
}
if !todoToolsWithNumericParentId[toolName] {
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 validateTodoParentIdNumeric(c)
}
}
// validateTodoParentIdNumeric inspects --parent-id; if non-empty it must
// parse as int64. Empty values are passed through so cobra's MarkFlagRequired
// (driven by the envelope's `"required": true`) still owns the missing-flag
// error message, matching the existing UX for other required flags.
//
// Error wording mirrors wukong (dws-wukong/wukong/products/todo.go ~L97) so
// agents and humans see a stable message across both editions. The
// apperrors.NewValidation wrapper guarantees stderr renders as
// "Error: [VALIDATION] ..." (PrintHumanAt) or `{"error":{...}}` (PrintJSON),
// both of which satisfy the auto-test substring assertion
// `"error" in result.stderr.lower()`.
func validateTodoParentIdNumeric(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
flag := cmd.Flags().Lookup("parent-id")
if flag == nil {
return nil
}
raw, err := cmd.Flags().GetString("parent-id")
if err != nil {
// Flag exists but type is not string — defensive no-op, do not block.
return nil
}
v := strings.TrimSpace(raw)
if v == "" {
return nil
}
if _, parseErr := strconv.ParseInt(v, 10, 64); parseErr != nil {
return apperrors.NewValidation(
fmt.Sprintf("父待办 ID 必须是纯数字, 当前值: %s", v),
apperrors.WithReason("invalid_parent_id"),
apperrors.WithHint("请通过 'dws todo task list' 获取正确的父待办任务 ID。"),
apperrors.WithOperation("todo.task.create-sub.parent-id"),
)
}
return nil
}
-217
View File
@@ -1,217 +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 (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newTodoCreateSubStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `todo task create-sub` (envelope:
// create_personal_sub_todo). Only the flags the hook touches are
// registered; the others are irrelevant to the validation.
func newTodoCreateSubStub() *cobra.Command {
cmd := &cobra.Command{Use: "create-sub"}
cmd.Flags().String("parent-id", "", "parent todo id")
cmd.Flags().String("title", "", "title")
cmd.Flags().String("executors", "", "executors")
return cmd
}
func TestValidateTodoParentIdNumeric_AcceptsPureDigits(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "53340859882"); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil for numeric parent-id, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_RejectsAlphanumeric(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "INVALID_PARENT_ID_99999"); err != nil {
t.Fatalf("set flag: %v", err)
}
err := validateTodoParentIdNumeric(cmd)
if err == nil {
t.Fatal("expected validation error for non-numeric parent-id")
}
if !strings.Contains(err.Error(), "纯数字") {
t.Fatalf("expected '纯数字' in error, got %v", err)
}
if !strings.Contains(err.Error(), "INVALID_PARENT_ID_99999") {
t.Fatalf("expected offending value in error, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_RejectsLeadingZeroPaddedHex(t *testing.T) {
// "0xdeadbeef" should fail strconv.ParseInt base 10, ensuring we are
// not silently accepting hex-shaped IDs.
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", "0xdeadbeef"); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err == nil {
t.Fatal("expected validation error for hex-shaped parent-id")
}
}
func TestValidateTodoParentIdNumeric_RejectsWhitespacePadded(t *testing.T) {
// Trimmed value is "abc" — must still reject; equally guards against
// " 123 " false-positive once trimmed (which we DO accept as 123).
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", " abc "); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err == nil {
t.Fatal("expected validation error for non-numeric (whitespace-padded) parent-id")
}
}
func TestValidateTodoParentIdNumeric_AcceptsWhitespacePaddedDigits(t *testing.T) {
cmd := newTodoCreateSubStub()
if err := cmd.Flags().Set("parent-id", " 53340859882 "); err != nil {
t.Fatalf("set flag: %v", err)
}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected whitespace-padded digits to pass after trim, got %v", err)
}
}
func TestValidateTodoParentIdNumeric_EmptyPassesThrough(t *testing.T) {
// Envelope marks parent-id required, so cobra produces the missing-flag
// error itself. We must not preempt that with a confusing message.
cmd := newTodoCreateSubStub()
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil for empty parent-id (cobra owns required-check), got %v", err)
}
}
func TestValidateTodoParentIdNumeric_NoFlagRegistered(t *testing.T) {
// Defensive: a command without the flag must not panic / error.
cmd := &cobra.Command{Use: "noop"}
if err := validateTodoParentIdNumeric(cmd); err != nil {
t.Fatalf("expected nil when --parent-id absent, got %v", err)
}
}
// ── installTodoHook composition ────────────────────────────────
func TestInstallTodoHook_NoOpForOtherProduct(t *testing.T) {
cmd := newTodoCreateSubStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installTodoHook(cmd, "chat", "create_personal_sub_todo")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE should still run when hook skips")
}
// Bad parent-id must NOT fail since hook is no-op for non-todo product.
if err := cmd.Flags().Set("parent-id", "INVALID"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("non-todo product must not validate parent-id: %v", err)
}
}
func TestInstallTodoHook_NoOpForOtherTodoTool(t *testing.T) {
// e.g. `todo task get` reuses parent-id-less plumbing — make sure we do
// not blanket-validate every todo leaf.
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "get_personal_todo_detail")
if err := cmd.Flags().Set("parent-id", "INVALID"); err != nil {
t.Fatal(err)
}
if cmd.PreRunE != nil {
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("non-target todo tool must not validate parent-id: %v", err)
}
}
}
func TestInstallTodoHook_TargetToolRejectsInvalid(t *testing.T) {
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if cmd.PreRunE == nil {
t.Fatal("installTodoHook should install a PreRunE for the target tool")
}
if err := cmd.Flags().Set("parent-id", "INVALID_PARENT_ID_99999"); err != nil {
t.Fatal(err)
}
err := cmd.PreRunE(cmd, nil)
if err == nil {
t.Fatal("expected hook to reject non-numeric parent-id")
}
if !strings.Contains(err.Error(), "纯数字") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestInstallTodoHook_TargetToolAcceptsValid(t *testing.T) {
cmd := newTodoCreateSubStub()
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if err := cmd.Flags().Set("parent-id", "53340859882"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("numeric parent-id must pass: %v", err)
}
}
func TestInstallTodoHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newTodoCreateSubStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
originalCalled = true
return nil
}
installTodoHook(cmd, "todo", "create_personal_sub_todo")
if err := cmd.Flags().Set("parent-id", "1"); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallTodoHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newTodoCreateSubStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installTodoHook(cmd, "todo", "create_personal_sub_todo")
// Even with a VALID parent-id, the chained original error must bubble up
// before our validation runs.
if err := cmd.Flags().Set("parent-id", "1"); err != nil {
t.Fatal(err)
}
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 TestInstallTodoHook_NilCmdSafe(t *testing.T) {
// Defensive: should not panic.
installTodoHook(nil, "todo", "create_personal_sub_todo")
}
-457
View File
@@ -1,457 +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 (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"gopkg.in/yaml.v3"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
return value, nil
case "iso8601_to_millis":
return transformISO8601ToMillis(value)
case "csv_to_array":
return transformCSVToArray(value)
case "json_parse":
return transformJSONParse(value)
case "json_parse_strict":
return transformJSONParseStrict(value)
case "enum_map":
return transformEnumMap(value, args)
case "file_read":
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
case "parse_bool":
return transformParseBool(value)
case "attendance_class_check_time":
return transformAttendanceClassCheckTime(value)
case "string_to_int64":
return transformStringToInt64(value)
default:
return value, nil
}
}
// transformInvertBool flips a boolean: true → false, false → true. Strings
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
// `transform: invert_bool` and the framework flips at send time.
func transformInvertBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return !v, nil
case string:
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "true", "1", "yes", "on":
return false, nil
case "false", "0", "no", "off", "":
return true, nil
}
return value, nil
default:
return value, nil
}
}
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
// zero value). Used by envelope flags that are semantically boolean but must be
// declared as string flags to accept an explicit `false` on the command line
// (cobra bool flags drop the space-form value). Unknown tokens pass through
// unchanged so upstream validators own the error wording.
func transformParseBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
}
return value, nil
default:
return value, nil
}
}
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
// every "HH:mm" checkTime under sections[*].times[*] and
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
// numeric form; the envelope cannot express this nested walk, so it lives here.
func transformAttendanceClassCheckTime(value any) (any, error) {
parsed, err := transformJSONParseStrict(value)
if err != nil {
return nil, err
}
classVO, ok := parsed.(map[string]any)
if !ok {
return parsed, nil
}
cst := time.FixedZone("CST", 8*3600)
convertOne := func(obj map[string]any) {
if ct, ok := obj["checkTime"].(string); ok {
ct = strings.TrimSpace(ct)
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
obj["checkTime"] = float64(t.UnixMilli())
}
}
}
if sections, ok := classVO["sections"].([]any); ok {
for _, sec := range sections {
if secMap, ok := sec.(map[string]any); ok {
if times, ok := secMap["times"].([]any); ok {
for _, t := range times {
if tMap, ok := t.(map[string]any); ok {
convertOne(tMap)
}
}
}
}
}
}
if setting, ok := classVO["setting"].(map[string]any); ok {
if restList, ok := setting["topRestTimeList"].([]any); ok {
for _, item := range restList {
if itemMap, ok := item.(map[string]any); ok {
convertOne(itemMap)
}
}
}
}
return classVO, nil
}
func transformISO8601ToMillis(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// Try direct millisecond integer first.
if millis, err := strconv.ParseInt(s, 10, 64); err == nil && millis > 1_000_000_000_000 {
return millis, nil
}
layouts := []struct {
layout string
location *time.Location
}{
{layout: time.RFC3339},
{layout: "2006-01-02T15:04:05"},
{layout: "2006-01-02 15:04:05"},
{layout: "2006-01-02", location: time.UTC},
}
for _, candidate := range layouts {
var (
parsed time.Time
err error
)
if candidate.location != nil {
parsed, err = time.ParseInLocation(candidate.layout, s, candidate.location)
} else {
parsed, err = time.Parse(candidate.layout, s)
}
if err == nil {
return parsed.UnixMilli(), nil
}
}
return nil, apperrors.NewValidation(fmt.Sprintf("iso8601_to_millis: cannot parse %q as ISO-8601", s))
}
func transformCSVToArray(value any) (any, error) {
s, ok := toString(value)
if !ok {
// If it's already a slice, pass through.
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return []any{}, nil
}
// If already looks like a JSON array, try parsing it.
if strings.HasPrefix(s, "[") {
var arr []any
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return arr, nil
}
}
parts := strings.Split(s, ",")
result := make([]any, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result, nil
}
// transformJSONParse parses a CLI string into a structured value so callers can
// pass complex payloads (JSON arrays/objects) through a single flag.
//
// Two input dialects are accepted, in order:
// 1. Strict JSON — `[{"fieldName":"x","type":"text"}]`
// 2. YAML (flow) — `[{fieldName: x, type: text}]`
//
// YAML is a superset of JSON that permits unquoted keys and strings, which
// dramatically reduces the need for shell-level escaping. Users can therefore
// write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes
// around every token. The output shape is the same either way; downstream
// consumers see the parsed Go value, not the original dialect.
func transformJSONParse(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
// YAML (flow) fallback — accepts `{key: value}` without surrounding
// quotes, which is the natural form when typing at a shell prompt.
if err := yaml.Unmarshal([]byte(s), &parsed); err == nil {
return parsed, nil
}
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
// structured array/object value and "garbage in → empty out" is unacceptable.
func transformJSONParseStrict(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
}
func transformEnumMap(value any, args map[string]any) (any, error) {
s, ok := toString(value)
if !ok {
s = fmt.Sprint(value)
}
s = strings.TrimSpace(s)
if mapped, exists := args[s]; exists {
return mapped, nil
}
if defaultVal, exists := args["_default"]; exists {
return defaultVal, nil
}
return value, nil
}
// transformFileRead reads the file at the given path and returns its contents
// as a UTF-8 string. The special path "-" reads from stdin.
//
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
// flag (--content "# 标题") feed the same parameter without conflict.
//
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
// (user input) rather than the generic exit code 1 (transient failure).
func transformFileRead(value any) (any, error) {
s, ok := toString(value)
if !ok {
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
}
s = strings.TrimSpace(s)
if s == "" {
return nil, apperrors.NewValidation("file_read: empty path")
}
var buf []byte
var err error
if s == "-" {
buf, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
}
} else {
buf, err = os.ReadFile(s)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
}
}
if !utf8.Valid(buf) {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
}
return string(buf), nil
}
// transformStringToInt64 parses a string-form integer (e.g. "12345") into an
// int64 so the MCP body carries a numeric value rather than a quoted string.
// Used for envelope flags whose upstream schema requires int64 (e.g. deptId).
//
// Two ergonomic guards are layered on top of the raw parse:
//
// 1. Placeholder rejection — common LLM/AI-agent placeholders for "myself" /
// "root department" (self / me / 我 / root / 0) are NOT valid deptIds. The
// dingtalk root department's deptId is the literal integer 1; if we let
// "self" fall through to the MCP, the server returns an empty result with
// success=true, masking the mistake. Instead, return a validation error
// pointing the caller at the correct usage. Mirrors wukong's cmdutil error
// wording ("根部门 deptId=1,请使用 --id 1") so CLI and wukong agree.
//
// 2. Non-numeric rejection — anything else that fails strconv.ParseInt is
// reported as a validation error rather than silently sent as a string,
// which the upstream server would also reject (or worse: coerce to 0).
//
// Numeric int / int64 inputs pass through unchanged; the transform is a no-op
// when the schema-typed flag already produced an integer.
func transformStringToInt64(value any) (any, error) {
switch v := value.(type) {
case nil:
return value, nil
case int:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
case float64:
// JSON numbers decode as float64; accept only when integer-valued.
if v == float64(int64(v)) {
return int64(v), nil
}
return nil, apperrors.NewValidation(fmt.Sprintf("string_to_int64: %v is not an integer", v))
}
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
// Placeholder guard: LLMs often invent symbolic values like "self" / "me" /
// "root" / "我" for "the current user's root department". Catch them with a
// clear error pointing at the canonical deptId=1, instead of forwarding the
// bogus value and letting the upstream return success=true with empty data.
lowered := strings.ToLower(s)
switch lowered {
case "self", "me", "我", "root", "0":
return nil, apperrors.NewValidation(
"flag --id 必须是整数;钉钉根部门 deptId=1,请使用 --id 1",
)
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("flag --id 必须是整数,got %q", s))
}
return n, nil
}
func toString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case fmt.Stringer:
return val.String(), true
default:
return "", false
}
}
-427
View File
@@ -1,427 +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 (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestJSONParse_StrictJSON covers the primary path: callers passing
// canonical JSON (as generated programmatically or by agents).
func TestJSONParse_StrictJSON(t *testing.T) {
t.Parallel()
input := `[{"fieldName":"title","type":"text"},{"fieldName":"count","type":"number"}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("strict JSON should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 2 {
t.Fatalf("expected []any of length 2, got %T %v", got, got)
}
}
// TestJSONParse_YAMLFlowFallback is the motivating case: a user types an
// ad-hoc JSON-shaped array without quoting every key and value. YAML flow
// syntax accepts it and the parsed output is indistinguishable from the
// strict-JSON equivalent.
func TestJSONParse_YAMLFlowFallback(t *testing.T) {
t.Parallel()
// Intentionally unquoted keys, unquoted string values, and Chinese
// identifiers — typical of what humans type at a shell.
input := `[{fieldName: 标题, type: text}, {fieldName: 数量, type: number, config: {formatter: INT}}, {fieldName: 状态, type: singleSelect, config: {options: [{name: 待办}, {name: 进行中}, {name: 已完成}]}}, {fieldName: 已确认, type: checkbox}]`
got, err := ApplyTransform(input, "json_parse", nil)
if err != nil {
t.Fatalf("YAML-flow input should parse, got err: %v", err)
}
arr, ok := got.([]any)
if !ok {
t.Fatalf("expected []any, got %T", got)
}
if len(arr) != 4 {
t.Fatalf("expected 4 field definitions, got %d", len(arr))
}
// Spot-check the third entry, which is the most deeply nested.
third, ok := arr[2].(map[string]any)
if !ok {
t.Fatalf("arr[2] expected map[string]any, got %T", arr[2])
}
if third["fieldName"] != "状态" {
t.Errorf("arr[2].fieldName: want 状态, got %v", third["fieldName"])
}
config, ok := third["config"].(map[string]any)
if !ok {
t.Fatalf("arr[2].config expected map, got %T", third["config"])
}
options, ok := config["options"].([]any)
if !ok || len(options) != 3 {
t.Fatalf("arr[2].config.options: want 3 items, got %v", config["options"])
}
}
// TestJSONParse_EmptyString preserves the legacy behaviour of returning the
// original value untouched when the caller passes an empty / whitespace-only
// string, matching how other transforms treat empty input.
func TestJSONParse_EmptyString(t *testing.T) {
t.Parallel()
cases := []string{"", " ", "\n\t"}
for _, in := range cases {
got, err := ApplyTransform(in, "json_parse", nil)
if err != nil {
t.Errorf("empty input %q should not error: %v", in, err)
continue
}
if !reflect.DeepEqual(got, in) {
t.Errorf("empty input %q should pass through, got %v", in, got)
}
}
}
// TestJSONParse_NonString passes through non-string inputs (already-parsed
// values flowing through the pipeline).
func TestJSONParse_NonString(t *testing.T) {
t.Parallel()
preParsed := []any{map[string]any{"k": "v"}}
got, err := ApplyTransform(preParsed, "json_parse", nil)
if err != nil {
t.Fatalf("non-string should pass through: %v", err)
}
if !reflect.DeepEqual(got, preParsed) {
t.Errorf("non-string should pass through unchanged, got %v", got)
}
}
// TestJSONParse_InvalidInput verifies that genuine garbage is still rejected
// with a user-facing validation error that nudges towards `@file` syntax.
func TestJSONParse_InvalidInput(t *testing.T) {
t.Parallel()
// Unterminated bracket — neither valid JSON nor valid YAML flow.
_, err := ApplyTransform("[{fieldName:", "json_parse", nil)
if err == nil {
t.Fatal("expected error for malformed input")
}
if msg := err.Error(); msg == "" {
t.Fatal("error message should be non-empty")
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
// file contents in place of the path.
func TestFileRead_BasicFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
contents := "# Heading\n\n- bullet one\n- bullet two\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
got, err := ApplyTransform(path, "file_read", nil)
if err != nil {
t.Fatalf("file_read should succeed, got err: %v", err)
}
if got != contents {
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
}
}
// TestFileRead_EmptyPath rejects empty input with a validation error rather
// than silently reading "" / cwd. The dispatcher maps validation errors to
// exit code 2 so the user sees a usage problem.
func TestFileRead_EmptyPath(t *testing.T) {
t.Parallel()
_, err := ApplyTransform("", "file_read", nil)
if err == nil {
t.Fatal("expected validation error for empty path")
}
if !strings.Contains(err.Error(), "file_read") {
t.Errorf("error should mention the transform name, got %q", err.Error())
}
}
// TestFileRead_MissingFile surfaces a clear validation error when the path
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
// sees what they passed.
func TestFileRead_MissingFile(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
_, err := ApplyTransform(missing, "file_read", nil)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "definitely-not-here.md") {
t.Errorf("error should mention the missing path, got %q", err.Error())
}
}
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
// content and silently shipping a corrupted byte string would mask a real
// user error.
func TestFileRead_InvalidUTF8(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "binary.dat")
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
_, err := ApplyTransform(path, "file_read", nil)
if err == nil {
t.Fatal("expected UTF-8 validation error for binary input")
}
if !strings.Contains(err.Error(), "UTF-8") {
t.Errorf("error should mention UTF-8, got %q", err.Error())
}
}
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
// silently no-op.
func TestFileRead_NonString(t *testing.T) {
t.Parallel()
_, err := ApplyTransform(123, "file_read", nil)
if err == nil {
t.Fatal("expected validation error for non-string value")
}
}
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
// "-" is reserved for stdin. We don't test stdin redirection here (that
// requires plumbing os.Stdin replacement which complicates the test) — this
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
// in the current directory. The end-to-end stdin path is covered in
// test/cli_compat once the envelope ships.
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
t.Parallel()
// Run with stdin redirected from an empty pipe so we don't hang.
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("setup: %v", err)
}
defer r.Close()
if _, err := w.Write([]byte("piped content")); err != nil {
t.Fatalf("setup: %v", err)
}
w.Close()
origStdin := os.Stdin
os.Stdin = r
defer func() { os.Stdin = origStdin }()
got, err := ApplyTransform("-", "file_read", nil)
if err != nil {
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
}
if got != "piped content" {
t.Errorf("expected stdin contents, got %q", got)
}
}
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
// is gated by name and doesn't regress when the transform name is missing.
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("./some-path", "", nil)
if err != nil {
t.Fatalf("empty transform should pass through, got err: %v", err)
}
if !reflect.DeepEqual(got, "./some-path") {
t.Errorf("expected pass-through, got %v", got)
}
}
func TestInvertBoolTransform(t *testing.T) {
cases := []struct {
in any
want any
}{
{true, false},
{false, true},
{"true", false},
{"false", true},
{"True", false},
{"FALSE", true},
{"on", false},
{"off", true},
{"", true},
}
for _, c := range cases {
got, err := ApplyTransform(c.in, "invert_bool", nil)
if err != nil {
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
}
}
}
// TestStringToInt64_NumericString covers the happy path: callers pass an
// integer-shaped string (the common CLI case where every flag arrives as text)
// and the transform promotes it to int64 so the MCP body carries a number.
func TestStringToInt64_NumericString(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("12345", "string_to_int64", nil)
if err != nil {
t.Fatalf("expected numeric string to parse, got err: %v", err)
}
if got != int64(12345) {
t.Fatalf("expected int64(12345), got %T %v", got, got)
}
}
// TestStringToInt64_NumericPassthrough covers the case where an upstream
// schema-typed flag already produced an integer (e.g. via pflag.Int64) — the
// transform should be a no-op and not double-convert.
func TestStringToInt64_NumericPassthrough(t *testing.T) {
t.Parallel()
cases := []any{int(7), int32(7), int64(7), float64(7)}
for _, in := range cases {
got, err := ApplyTransform(in, "string_to_int64", nil)
if err != nil {
t.Errorf("expected pass-through for %T(%v), got err: %v", in, in, err)
continue
}
if got != int64(7) {
t.Errorf("expected int64(7), got %T %v (input %T)", got, got, in)
}
}
}
// TestStringToInt64_PlaceholderRejected guards the wukong-aligned error wording
// for LLM/AI-agent placeholders. Each of these values must surface a
// validation error pointing at the canonical root deptId=1; if they fell
// through silently the MCP server would return success=true with empty data
// and the caller would never learn they sent garbage.
func TestStringToInt64_PlaceholderRejected(t *testing.T) {
t.Parallel()
placeholders := []string{"self", "me", "我", "root", "0", "SELF", "Me"}
for _, p := range placeholders {
_, err := ApplyTransform(p, "string_to_int64", nil)
if err == nil {
t.Errorf("placeholder %q should reject, got nil error", p)
continue
}
msg := err.Error()
if !strings.Contains(msg, "根部门") || !strings.Contains(msg, "deptId=1") {
t.Errorf("placeholder %q error should mention 根部门/deptId=1, got %q", p, msg)
}
}
}
// TestStringToInt64_NonNumericRejected ensures non-integer strings are
// surfaced as validation errors (exit code 2) rather than forwarded to the
// MCP as a quoted string, which the upstream would reject anyway.
func TestStringToInt64_NonNumericRejected(t *testing.T) {
t.Parallel()
_, err := ApplyTransform("abc", "string_to_int64", nil)
if err == nil {
t.Fatalf("non-numeric input should reject, got nil error")
}
if !strings.Contains(err.Error(), "必须是整数") {
t.Errorf("expected `必须是整数` in error, got %q", err.Error())
}
}
// TestStringToInt64_EmptyPassthrough mirrors the other transforms' contract:
// empty input is a no-op so optional flags that weren't provided don't trip
// the placeholder/format guards.
func TestStringToInt64_EmptyPassthrough(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("", "string_to_int64", nil)
if err != nil {
t.Fatalf("empty string should pass through, got err: %v", err)
}
if got != "" {
t.Errorf("expected empty string pass-through, got %v", got)
}
}
-171
View File
@@ -1,171 +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
package compat
import (
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestKindFromTypeName covers the schema v3 explicit Type field → ValueKind map.
func TestKindFromTypeName(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want ValueKind
}{
{"", ValueString},
{"string", ValueString},
{"STRING", ValueString},
{"int", ValueInt},
{"integer", ValueInt},
{"number", ValueInt},
{"bool", ValueBool},
{"boolean", ValueBool},
{"stringSlice", ValueStringSlice},
{"string_slice", ValueStringSlice},
{"[]string", ValueStringSlice},
{"weird-unknown-type", ValueString},
}
for _, tc := range cases {
if got := kindFromTypeName(tc.in); got != tc.want {
t.Errorf("kindFromTypeName(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
// TestApplyOmitWhen verifies all three modes plus zero-detection.
func TestApplyOmitWhen(t *testing.T) {
t.Parallel()
t.Run("mode_zero_drops_zero_values", func(t *testing.T) {
params := map[string]any{
"a": 0,
"b": "",
"c": false,
"d": []any{},
"e": map[string]any{},
"f": "non-empty",
"g": 42,
}
for k := range params {
applyOmitWhen(params, k, "zero")
}
for _, k := range []string{"a", "b", "c", "d", "e"} {
if _, ok := params[k]; ok {
t.Errorf("expected key %q to be dropped by omitWhen=zero", k)
}
}
for _, k := range []string{"f", "g"} {
if _, ok := params[k]; !ok {
t.Errorf("expected key %q to be preserved", k)
}
}
})
t.Run("mode_never_preserves_zero_values", func(t *testing.T) {
params := map[string]any{"explicitFalse": false, "explicitZero": 0}
applyOmitWhen(params, "explicitFalse", "never")
applyOmitWhen(params, "explicitZero", "never")
if len(params) != 2 {
t.Errorf("expected both keys preserved under 'never', got %v", params)
}
})
t.Run("mode_empty_is_noop", func(t *testing.T) {
params := map[string]any{"a": ""}
applyOmitWhen(params, "a", "empty")
if _, ok := params["a"]; !ok {
t.Errorf("empty mode should not drop keys here (upstream CollectBindings does)")
}
})
t.Run("missing_key_safe", func(t *testing.T) {
params := map[string]any{}
applyOmitWhen(params, "nope", "zero")
if len(params) != 0 {
t.Errorf("expected no-op on missing key, got %v", params)
}
})
}
// TestIsZeroValue covers every branch of the helper.
func TestIsZeroValue(t *testing.T) {
t.Parallel()
zeros := []any{
nil,
"",
" ",
false,
0,
int64(0),
float64(0),
[]any{},
[]string{},
map[string]any{},
}
for i, z := range zeros {
if !isZeroValue(z) {
t.Errorf("case %d: expected zero value for %#v", i, z)
}
}
nonZeros := []any{"x", true, 1, int64(1), float64(1.5), []any{1}, []string{"a"}, map[string]any{"k": 1}}
for i, nz := range nonZeros {
if isZeroValue(nz) {
t.Errorf("case %d: expected non-zero for %#v", i, nz)
}
}
}
// TestRuntimeDefaultResolvers_BuiltIns asserts $now and $today always resolve.
func TestRuntimeDefaultResolvers_BuiltIns(t *testing.T) {
// NOTE: not t.Parallel — edition.Get() global state is shared.
resolvers := runtimeDefaultResolvers()
now := resolvers["$now"]
if now == nil {
t.Fatal("$now resolver missing")
}
if v, ok := now(context.Background()); !ok || v == "" {
t.Errorf("$now returned empty value: %q ok=%v", v, ok)
}
today := resolvers["$today"]
if today == nil {
t.Fatal("$today resolver missing")
}
if v, ok := today(context.Background()); !ok || !strings.Contains(v, "-") {
t.Errorf("$today returned unexpected value: %q ok=%v", v, ok)
}
}
// TestRuntimeDefaultResolvers_OverlayMerge covers the edition overlay hook.
func TestRuntimeDefaultResolvers_OverlayMerge(t *testing.T) {
prev := edition.Get()
defer edition.Override(prev)
edition.Override(&edition.Hooks{
RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$currentUserId": func(ctx context.Context) (string, bool) {
return "test-user-001", true
},
}
},
})
resolvers := runtimeDefaultResolvers()
fn := resolvers["$currentUserId"]
if fn == nil {
t.Fatal("$currentUserId missing after overlay install")
}
if v, ok := fn(context.Background()); !ok || v != "test-user-001" {
t.Errorf("$currentUserId=%q ok=%v", v, ok)
}
}
-464
View File
@@ -1,464 +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 discovery
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"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/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_TENANT",
Category: configmeta.CategoryCore,
Description: "缓存分区的租户标识",
DefaultValue: "default",
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_AUTH_IDENTITY",
Category: configmeta.CategorySecurity,
Description: "缓存分区的认证身份标识",
DefaultValue: "default",
})
}
const (
tenantEnv = "DWS_TENANT"
authIdentityEnv = "DWS_AUTH_IDENTITY"
)
var errCLIServerSkipped = errors.New("server marked cli.skip")
type Service struct {
MarketClient *market.Client
Transport *transport.Client
Cache *cache.Store
Tenant string
AuthIdentity string
Logger *slog.Logger
// PerServerTimeout overrides the default per-server discovery timeout
// when greater than zero. Useful for tests and for callers that need a
// tighter or looser bound. When zero, defaultPerServerDiscoveryTimeout
// applies.
PerServerTimeout time.Duration
}
type RuntimeServer struct {
Server market.ServerDescriptor `json:"server"`
NegotiatedProtocolVersion string `json:"negotiated_protocol_version"`
Tools []transport.ToolDescriptor `json:"tools"`
Source string `json:"source"`
Degraded bool `json:"degraded"`
}
type RuntimeFailure struct {
ServerKey string
Err error
}
func NewService(marketClient *market.Client, transportClient *transport.Client, cacheStore *cache.Store) *Service {
return &Service{
MarketClient: marketClient,
Transport: transportClient,
Cache: cacheStore,
Tenant: resolveTenant(),
AuthIdentity: resolveAuthIdentity(),
}
}
func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescriptor, error) {
partition := s.partition()
response, err := s.MarketClient.FetchServers(ctx, 200)
if err == nil {
servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL)
_ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
return servers, nil
}
snapshot, freshness, cacheErr := s.Cache.LoadRegistry(partition)
if cacheErr == nil {
servers := append([]market.ServerDescriptor(nil), snapshot.Servers...)
for idx := range servers {
servers[idx].Source = string(freshness) + "_cache"
servers[idx].Degraded = true
}
return servers, nil
}
return nil, fmt.Errorf("discover servers: market fetch failed and no cache available: %w", err)
}
func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.ServerDescriptor) (RuntimeServer, error) {
if server.CLI.Skip {
return RuntimeServer{}, errCLIServerSkipped
}
partition := s.partition()
initialize, err := s.Transport.Initialize(ctx, server.Endpoint)
if err == nil {
// NotifyInitialized is best-effort; log but do not fail on error.
if notifyErr := s.Transport.NotifyInitialized(ctx, server.Endpoint); notifyErr != nil && s.Logger != nil {
s.Logger.Debug("NotifyInitialized failed", "server", server.Key, "error", notifyErr)
}
tools, listErr := s.Transport.ListTools(ctx, server.Endpoint)
if listErr == nil {
runtimeTools := append([]transport.ToolDescriptor(nil), tools.Tools...)
var actionVersions map[string]string
if server.DetailLocator.MCPID > 0 {
if detail, detailErr := s.DiscoverDetail(ctx, server); detailErr == nil {
runtimeTools = mergeRuntimeToolsWithDetail(runtimeTools, detail)
actionVersions = cache.ExtractActionVersions(detail.Result.Tools)
}
}
_ = s.Cache.SaveTools(partition, server.Key, cache.ToolsSnapshot{
ServerKey: server.Key,
ProtocolVersion: initialize.ProtocolVersion,
Tools: runtimeTools,
ActionVersions: actionVersions,
})
server.NegotiatedProtocolVersion = initialize.ProtocolVersion
server.Source = "live_runtime"
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: initialize.ProtocolVersion,
Tools: runtimeTools,
Source: "live_runtime",
Degraded: false,
}, nil
}
err = listErr
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return RuntimeServer{}, err
}
snapshot, freshness, cacheErr := s.Cache.LoadTools(partition, server.Key)
if cacheErr != nil {
return RuntimeServer{}, fmt.Errorf("server %s: runtime discovery failed and no cache available: %w", server.Key, err)
}
server.NegotiatedProtocolVersion = snapshot.ProtocolVersion
server.Source = string(freshness) + "_cache"
server.Degraded = true
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: snapshot.ProtocolVersion,
Tools: snapshot.Tools,
Source: string(freshness) + "_cache",
Degraded: true,
}, nil
}
// defaultPerServerDiscoveryTimeout bounds the time spent discovering tools on
// a single registry-listed server. Tightened to 2s so a slow/unreachable
// server cannot stall every CLI command — a healthy MCP endpoint negotiates
// well under a second. See issue #119.
const defaultPerServerDiscoveryTimeout = 2 * time.Second
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
type discoveryResult struct {
server RuntimeServer
failure *RuntimeFailure
}
perServerTimeout := defaultPerServerDiscoveryTimeout
if s.PerServerTimeout > 0 {
perServerTimeout = s.PerServerTimeout
}
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, srv := range servers {
if !srv.CLI.Skip {
filtered = append(filtered, srv)
}
}
if len(filtered) == 0 {
return nil, nil
}
ch := make(chan discoveryResult, len(filtered))
var wg sync.WaitGroup
for _, srv := range filtered {
wg.Add(1)
go func(server market.ServerDescriptor) {
defer wg.Done()
serverCtx, cancel := context.WithTimeout(ctx, perServerTimeout)
defer cancel()
start := time.Now()
rs, err := s.DiscoverServerRuntime(serverCtx, server)
elapsed := time.Since(start)
if err != nil {
if errors.Is(err, errCLIServerSkipped) {
return
}
if s.Logger != nil {
s.Logger.Warn("server_discovery_failed",
slog.String("server_key", server.Key),
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
slog.String("error", err.Error()),
slog.Bool("is_timeout", errors.Is(err, context.DeadlineExceeded)),
)
}
// Per-server sub-context timed out but parent is still alive:
// try cache fallback instead of reporting a hard failure.
if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
if cached, cacheErr := s.loadServerFromCache(server); cacheErr == nil {
if s.Logger != nil {
s.Logger.Info("server_discovery_cache_fallback",
slog.String("server_key", server.Key),
slog.String("source", cached.Source),
)
}
ch <- discoveryResult{server: cached}
return
}
}
ch <- discoveryResult{failure: &RuntimeFailure{ServerKey: server.Key, Err: err}}
return
}
if s.Logger != nil {
s.Logger.Debug("server_discovery_ok",
slog.String("server_key", server.Key),
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
slog.String("source", rs.Source),
)
}
ch <- discoveryResult{server: rs}
}(srv)
}
go func() {
wg.Wait()
close(ch)
}()
results := make([]RuntimeServer, 0, len(filtered))
failures := make([]RuntimeFailure, 0)
for dr := range ch {
if dr.failure != nil {
failures = append(failures, *dr.failure)
} else {
results = append(results, dr.server)
}
}
return results, failures
}
// loadServerFromCache tries to load a server's tools from cache, returning a
// degraded RuntimeServer. Used as fallback when a per-server discovery timeout
// fires but the parent context is still alive.
func (s *Service) loadServerFromCache(server market.ServerDescriptor) (RuntimeServer, error) {
partition := s.partition()
snapshot, freshness, err := s.Cache.LoadTools(partition, server.Key)
if err != nil {
return RuntimeServer{}, err
}
server.NegotiatedProtocolVersion = snapshot.ProtocolVersion
server.Source = string(freshness) + "_cache"
server.Degraded = true
return RuntimeServer{
Server: server,
NegotiatedProtocolVersion: snapshot.ProtocolVersion,
Tools: snapshot.Tools,
Source: string(freshness) + "_cache",
Degraded: true,
}, nil
}
func (s *Service) DiscoverDetail(ctx context.Context, server market.ServerDescriptor) (market.DetailResponse, error) {
partition := s.partition()
var fetchErr error
if detailURL := strings.TrimSpace(server.DetailLocator.DetailURL); detailURL != "" {
detail, err := s.MarketClient.FetchDetailByURL(ctx, detailURL)
if err == nil {
cacheDetailSnapshot(s.Cache, partition, server, server.DetailLocator.MCPID, detail)
s.invalidateToolsIfVersionChanged(partition, server.Key, detail)
return detail, nil
}
fetchErr = err
}
if server.DetailLocator.MCPID > 0 {
detail, err := s.MarketClient.FetchDetail(ctx, server.DetailLocator.MCPID)
if err == nil {
cacheDetailSnapshot(s.Cache, partition, server, server.DetailLocator.MCPID, detail)
s.invalidateToolsIfVersionChanged(partition, server.Key, detail)
return detail, nil
}
fetchErr = err
}
if fetchErr == nil {
fetchErr = fmt.Errorf("server %s does not expose detail locator", server.Key)
}
snapshot, _, cacheErr := s.Cache.LoadDetail(partition, server.Key)
if cacheErr != nil {
return market.DetailResponse{}, fetchErr
}
if server.DetailLocator.MCPID > 0 && snapshot.MCPID != server.DetailLocator.MCPID {
return market.DetailResponse{}, fetchErr
}
var cached market.DetailResponse
if unmarshalErr := json.Unmarshal(snapshot.Payload, &cached); unmarshalErr != nil {
return market.DetailResponse{}, fetchErr
}
return cached, nil
}
// invalidateToolsIfVersionChanged checks whether a fresh Detail API response
// contains actionVersion values that differ from those stored in the cached
// tools snapshot. If any tool's version has changed, the tools cache is
// invalidated so the next DiscoverServerRuntime call re-fetches tools/list.
func (s *Service) invalidateToolsIfVersionChanged(partition, serverKey string, detail market.DetailResponse) {
if !detail.Success || len(detail.Result.Tools) == 0 {
return
}
snapshot, _, err := s.Cache.LoadTools(partition, serverKey)
if err != nil || len(snapshot.ActionVersions) == 0 {
return
}
if cache.HasActionVersionChanged(snapshot.ActionVersions, detail.Result.Tools) {
_ = s.Cache.DeleteTools(partition, serverKey)
}
}
func (s *Service) partition() string {
return fmt.Sprintf("%s/%s", s.Tenant, s.AuthIdentity)
}
func (s *Service) CachePartition() string {
return s.partition()
}
func mergeRuntimeToolsWithDetail(tools []transport.ToolDescriptor, detail market.DetailResponse) []transport.ToolDescriptor {
if !detail.Success || len(detail.Result.Tools) == 0 || len(tools) == 0 {
return tools
}
byName := make(map[string]market.DetailTool, len(detail.Result.Tools))
for _, tool := range detail.Result.Tools {
name := strings.TrimSpace(tool.ToolName)
if name == "" {
continue
}
byName[name] = tool
}
out := make([]transport.ToolDescriptor, 0, len(tools))
for _, tool := range tools {
merged := tool
detailTool, ok := byName[strings.TrimSpace(tool.Name)]
if !ok {
out = append(out, merged)
continue
}
if title := strings.TrimSpace(detailTool.ToolTitle); title != "" {
merged.Title = title
}
if description := strings.TrimSpace(detailTool.ToolDesc); description != "" {
merged.Description = description
}
merged.Sensitive = detailTool.IsSensitive
if inputSchema := parseDetailSchema(detailTool.ToolRequest); len(inputSchema) > 0 {
merged.InputSchema = inputSchema
}
if outputSchema := parseDetailSchema(detailTool.ToolResponse); len(outputSchema) > 0 {
merged.OutputSchema = outputSchema
}
out = append(out, merged)
}
return out
}
func parseDetailSchema(raw string) map[string]any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil
}
object, ok := parsed.(map[string]any)
if !ok {
return nil
}
return object
}
func cacheDetailSnapshot(store *cache.Store, partition string, server market.ServerDescriptor, mcpID int, detail market.DetailResponse) {
if store == nil {
return
}
raw, err := json.Marshal(detail)
if err != nil {
return
}
snapshot := cache.DetailSnapshot{
MCPID: mcpID,
Payload: raw,
}
for _, cacheKey := range detailSnapshotKeys(server) {
_ = store.SaveDetail(partition, cacheKey, snapshot)
}
}
func detailSnapshotKeys(server market.ServerDescriptor) []string {
seen := make(map[string]struct{}, 2)
keys := make([]string, 0, 2)
for _, candidate := range []string{
strings.TrimSpace(server.Key),
strings.TrimSpace(server.CLI.ID),
} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
keys = append(keys, candidate)
}
return keys
}
func resolveTenant() string {
value := strings.TrimSpace(os.Getenv(tenantEnv))
if value == "" {
return "default"
}
return strings.ToLower(value)
}
func resolveAuthIdentity() string {
if value := strings.TrimSpace(os.Getenv(authIdentityEnv)); value != "" {
return strings.ToLower(value)
}
return "default"
}
-459
View File
@@ -1,459 +0,0 @@
package discovery
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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/transport"
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
}
// Return one server whose MCP endpoint is this same test server.
resp := map[string]any{
"metadata": map[string]any{"count": 1},
"servers": []map[string]any{{
"server": map[string]any{
"name": "test-server",
"description": "Test server",
"remotes": []map[string]any{{"type": "streamable-http", "url": "PLACEHOLDER"}},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{"mcpId": 0, "status": "active"},
"com.dingtalk.mcp.registry/cli": map[string]any{"id": "test", "command": "test", "description": "Test CLI"},
},
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
if !mcpOK {
http.Error(w, "mcp unavailable", http.StatusInternalServerError)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
method, _ := req["method"].(string)
id := req["id"]
switch method {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "test", "version": "1.0"},
},
})
case "notifications/initialized":
// Notification — no response needed, but respond 200 with empty.
w.WriteHeader(http.StatusOK)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"tools": []map[string]any{{
"name": "search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
}},
},
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": -32601, "message": "method not found"},
})
}
})
return httptest.NewServer(mux)
}
func newTestService(t *testing.T, baseURL string, mcpServer *httptest.Server) *Service {
t.Helper()
cacheDir := t.TempDir()
return &Service{
MarketClient: market.NewClient(baseURL, mcpServer.Client()),
Transport: transport.NewClient(mcpServer.Client()),
Cache: cache.NewStore(cacheDir),
Tenant: "test-tenant",
AuthIdentity: "test-identity",
}
}
func TestDiscoverServers_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers")
}
if servers[0].Source != "live_market" {
t.Fatalf("Source = %q, want live_market", servers[0].Source)
}
}
func TestDiscoverServers_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
// Populate cache with a live fetch first.
_, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("initial DiscoverServers() error = %v", err)
}
// Now point market at a broken URL so the next fetch fails.
svc.MarketClient = market.NewClient("http://127.0.0.1:1/broken", nil)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() with cache fallback error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers from cache")
}
if !servers[0].Degraded {
t.Fatal("cached servers should be marked Degraded")
}
}
func TestDiscoverServers_NoMarketNoCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, false, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
_, err := svc.DiscoverServers(context.Background())
if err == nil {
t.Fatal("DiscoverServers() error = nil, want error when both market and cache fail")
}
}
func TestDiscoverServerRuntime_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "test-key",
Endpoint: srv.URL + "/mcp",
}
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() error = %v", err)
}
if result.Source != "live_runtime" {
t.Fatalf("Source = %q, want live_runtime", result.Source)
}
if result.Degraded {
t.Fatal("live result should not be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("expected at least one tool")
}
if result.Tools[0].Name != "search" {
t.Fatalf("tool name = %q, want search", result.Tools[0].Name)
}
if result.NegotiatedProtocolVersion != "2025-03-26" {
t.Fatalf("protocol = %q, want 2025-03-26", result.NegotiatedProtocolVersion)
}
}
func TestDiscoverServerRuntime_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "cache-test",
Endpoint: srv.URL + "/mcp",
}
// First call populates cache.
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("initial DiscoverServerRuntime() error = %v", err)
}
// Now use a broken MCP endpoint so initialize fails.
server.Endpoint = "http://127.0.0.1:1/broken"
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() with cache fallback error = %v", err)
}
if !result.Degraded {
t.Fatal("cached result should be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("cached tools should be present")
}
}
func TestDiscoverServerRuntime_NoCacheNoLive(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "fail-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want error")
}
// Error should contain server key for debugging.
if got := err.Error(); !contains(got, "fail-key") {
t.Fatalf("error %q should contain server key 'fail-key'", got)
}
}
func TestDiscoverServerRuntime_ContextCanceled(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
server := market.ServerDescriptor{
Key: "cancel-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(ctx, server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want context canceled")
}
}
func TestDiscoverAllRuntime_PartialFailure(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{Key: "good", Endpoint: srv.URL + "/mcp"},
{Key: "bad", Endpoint: "http://127.0.0.1:1/broken"},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "good" {
t.Fatalf("successful server key = %q, want good", results[0].Server.Key)
}
if len(failures) != 1 {
t.Fatalf("failures count = %d, want 1", len(failures))
}
if failures[0].ServerKey != "bad" {
t.Fatalf("failed server key = %q, want bad", failures[0].ServerKey)
}
}
func TestDiscoverAllRuntime_SkipsCLISkippedServersWithoutWritingCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{
Key: "active",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "active", Command: "active"},
},
{
Key: "skipped",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(failures) != 0 {
t.Fatalf("failures count = %d, want 0", len(failures))
}
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "active" {
t.Fatalf("successful server key = %q, want active", results[0].Server.Key)
}
if _, _, err := svc.Cache.LoadTools("test-tenant/test-identity", "skipped"); err == nil {
t.Fatal("LoadTools(skipped) error = nil, want skipped service to avoid cache writes")
}
}
func TestCacheDetailSnapshotWritesServerKeyAndCLIID(t *testing.T) {
t.Parallel()
store := cache.NewStore(t.TempDir())
server := market.ServerDescriptor{
Key: "server-key",
CLI: market.CLIOverlay{ID: "server-id"},
}
cacheDetailSnapshot(store, "test-tenant/test-identity", server, 42, market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{{ToolName: "search", ToolTitle: "Search"}},
},
})
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-key"); err != nil {
t.Fatalf("LoadDetail(server-key) error = %v", err)
}
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-id"); err != nil {
t.Fatalf("LoadDetail(server-id) error = %v", err)
}
}
func TestMergeRuntimeToolsWithDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{
{Name: "search", Description: "original desc"},
{Name: "create", Description: "create desc"},
}
detail := market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "search", ToolTitle: "Search Title", ToolDesc: "Updated desc", IsSensitive: true},
},
},
}
merged := mergeRuntimeToolsWithDetail(tools, detail)
if len(merged) != 2 {
t.Fatalf("merged count = %d, want 2", len(merged))
}
// "search" should be updated from detail.
if merged[0].Title != "Search Title" {
t.Fatalf("search title = %q, want 'Search Title'", merged[0].Title)
}
if merged[0].Description != "Updated desc" {
t.Fatalf("search description = %q, want 'Updated desc'", merged[0].Description)
}
if !merged[0].Sensitive {
t.Fatal("search should be marked sensitive")
}
// "create" should remain unchanged.
if merged[1].Description != "create desc" {
t.Fatalf("create description = %q, want original", merged[1].Description)
}
}
func TestMergeRuntimeToolsWithDetail_EmptyDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{{Name: "t1"}}
empty := market.DetailResponse{Success: false}
result := mergeRuntimeToolsWithDetail(tools, empty)
if len(result) != 1 || result[0].Name != "t1" {
t.Fatal("empty detail should return original tools unchanged")
}
}
func TestParseDetailSchema(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNil bool
}{
{"valid object", `{"type":"object"}`, false},
{"empty string", "", true},
{"invalid json", "not-json", true},
{"json array", `[1,2,3]`, true},
{"whitespace only", " ", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDetailSchema(tt.input)
if tt.wantNil && result != nil {
t.Fatalf("parseDetailSchema(%q) = %v, want nil", tt.input, result)
}
if !tt.wantNil && result == nil {
t.Fatalf("parseDetailSchema(%q) = nil, want non-nil", tt.input)
}
})
}
}
func TestPartition(t *testing.T) {
t.Parallel()
svc := &Service{Tenant: "corp1", AuthIdentity: "user1"}
got := svc.partition()
if got != "corp1/user1" {
t.Fatalf("partition() = %q, want corp1/user1", got)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && searchString(s, substr)))
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
-87
View File
@@ -1,87 +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 editionmerge converts edition.ServerInfo hooks into
// market.ServerDescriptor values and merges them into discovery results.
//
// This package exists so both internal/cli (runtime catalog loader) and
// internal/app (command-tree loader) can apply the edition's
// SupplementServers / FallbackServers hooks consistently against the same
// discovery pipeline, instead of the hooks being wired only at the
// command-tree layer. Keeping the logic here avoids an import cycle
// between internal/cli ↔ internal/app.
package editionmerge
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// MergeSupplement returns servers augmented with the active edition's
// SupplementServers hook. Discovery entries always win on ID collision —
// the supplement only fills gaps that discovery did not cover.
func MergeSupplement(servers []market.ServerDescriptor) []market.ServerDescriptor {
fn := edition.Get().SupplementServers
if fn == nil {
return servers
}
existing := make(map[string]bool, len(servers))
for _, s := range servers {
if id := s.CLI.ID; id != "" {
existing[id] = true
}
if s.Key != "" {
existing[s.Key] = true
}
}
for _, sup := range fn() {
if sup.ID == "" || existing[sup.ID] {
continue
}
servers = append(servers, ToDescriptor(sup, "edition_supplement"))
}
return servers
}
// FallbackToDescriptors converts the edition's FallbackServers hook into
// market.ServerDescriptor values. Callers should only invoke this when
// live discovery returned zero servers and the cache is also empty.
func FallbackToDescriptors(servers []edition.ServerInfo) []market.ServerDescriptor {
out := make([]market.ServerDescriptor, 0, len(servers))
for _, s := range servers {
out = append(out, ToDescriptor(s, "edition_fallback"))
}
return out
}
// ToDescriptor is the shared conversion from edition.ServerInfo to the
// market descriptor shape expected by downstream consumers.
//
// Source carries the origin tag for diagnostics / metrics. Supplement and
// fallback entries intentionally carry no ToolOverrides — that keeps
// internal/compat.BuildDynamicCommands from materialising parallel
// command trees for products already owned by hardcoded overlays (see
// internal/compat/dynamic_commands.go's CLIOverlay gate).
func ToDescriptor(s edition.ServerInfo, source string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
Source: source,
CLI: market.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
},
}
}
-123
View File
@@ -1,123 +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 editionmerge
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func restoreEdition(t *testing.T) {
t.Helper()
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
}
func TestMergeSupplement_DiscoveryWinsOnCollision(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://hardcoded/conference"},
{ID: "doc", Name: "文档(overridden)", Endpoint: "https://hardcoded/doc"},
}
},
})
servers := []market.ServerDescriptor{
{
Key: "doc",
DisplayName: "文档",
Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"},
},
}
merged := MergeSupplement(servers)
if len(merged) != 2 {
t.Fatalf("merged len = %d, want 2", len(merged))
}
byID := make(map[string]market.ServerDescriptor, len(merged))
for _, m := range merged {
byID[m.CLI.ID] = m
}
if got := byID["doc"].Endpoint; got != "https://live/doc" {
t.Errorf("doc endpoint = %q, want live endpoint (discovery wins)", got)
}
if got := byID["conference"].Endpoint; got != "https://hardcoded/conference" {
t.Errorf("conference endpoint = %q, want supplement endpoint", got)
}
if got := byID["conference"].Source; got != "edition_supplement" {
t.Errorf("conference Source = %q, want edition_supplement", got)
}
}
func TestMergeSupplement_NilHookIsNoop(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{})
servers := []market.ServerDescriptor{
{Key: "doc", DisplayName: "文档", Endpoint: "https://live/doc",
CLI: market.CLIOverlay{ID: "doc", Command: "doc"}},
}
merged := MergeSupplement(servers)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (no supplement hook registered)", len(merged))
}
}
func TestMergeSupplement_EmptyIDSkipped(t *testing.T) {
restoreEdition(t)
edition.Override(&edition.Hooks{
SupplementServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "empty", Endpoint: "https://example.invalid/empty"},
{ID: "valid", Name: "valid", Endpoint: "https://example.invalid/valid"},
}
},
})
merged := MergeSupplement(nil)
if len(merged) != 1 {
t.Fatalf("merged len = %d, want 1 (empty ID must be skipped)", len(merged))
}
if merged[0].CLI.ID != "valid" {
t.Errorf("merged[0].CLI.ID = %q, want valid", merged[0].CLI.ID)
}
}
func TestFallbackToDescriptors(t *testing.T) {
got := FallbackToDescriptors([]edition.ServerInfo{
{ID: "conference", Name: "会议", Endpoint: "https://example.invalid/conference", Prefixes: []string{"conference", "meeting"}},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
g := got[0]
if g.CLI.ID != "conference" || g.CLI.Command != "conference" {
t.Errorf("CLI overlay not wired: %+v", g.CLI)
}
if g.Source != "edition_fallback" {
t.Errorf("Source = %q, want edition_fallback", g.Source)
}
if len(g.CLI.ToolOverrides) != 0 {
t.Errorf("fallback descriptor must not carry ToolOverrides; got %v", g.CLI.ToolOverrides)
}
}
+525
View File
@@ -0,0 +1,525 @@
// 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 bus
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// SourceAdapter is the interface daemon.go uses to talk to the cloud Source
// (in practice internal/event/source.DingtalkSource). Kept abstract so the
// bus daemon can be tested without spinning up the real Stream SDK; the
// integration test substitutes a fake.
type SourceAdapter interface {
// Start opens the cloud connection and blocks until ctx is cancelled
// or a fatal error occurs. emit is called for each incoming event.
Start(ctx context.Context, emit dwsevent.EmitFn) error
}
// Config bundles everything Run needs to start a daemon. All paths and
// identifiers come from busctl/Source so the bus itself stays oblivious
// to ConfigDir / edition rules.
type Config struct {
// WorkDir is the bus working directory:
// <ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/
// The caller MUST mkdir this with pkg/config.DirPerm before calling Run.
WorkDir string
// IPCEndpoint is the Unix socket path or Windows pipe name. Caller
// computes this from WorkDir (Unix) or edition/clientIDHash (Windows).
IPCEndpoint string
// ClientID is the human-readable identifier written into bus.meta and
// status output. NOT used in any path.
ClientID string
// SourceKind/IdentityHash/SourceID are diagnostic identity fields used by
// list/status. Empty SourceKind is interpreted as app_stream for backward
// compatibility.
SourceKind dwsevent.SourceKind
IdentityHash string
SourceID string
// Edition is written into bus.meta. Comes from edition.Get().Name with
// "open" fallback applied by the caller.
Edition string
// SDKVersion is recorded in bus.meta for diagnostics.
SDKVersion string
// Source is the cloud adapter. Required.
Source SourceAdapter
// IdleTimeout: bus self-exits after this long with zero consumers.
// Zero disables (bus runs until SIGTERM).
IdleTimeout time.Duration
// ConsumerBuffer overrides per-consumer sendCh capacity. Zero uses
// DefaultSendBuffer.
ConsumerBuffer int
// DedupCapacity overrides event_id LRU size. Zero uses dedup.DefaultCapacity.
DedupCapacity int
// DropWarnPercent is the per-event-type drop-rate threshold (whole
// percentage points) that triggers a slog WARN in bus.log. Zero or
// out-of-range values fall back to DefaultDropWarnPercent. Overridable
// via env DWS_EVENT_DROP_WARN_PCT (read by the cobra layer).
DropWarnPercent int
// ReadyPipe receives a single byte ('R' on success, 'E' on failure)
// once the bus has either come up or failed startup, so the parent
// process forked by busctl/spawn can stop polling and either dial or
// surface the error. nil disables (foreground mode).
ReadyPipe *os.File
// Logger sink. Nil → slog.Default.
Logger *slog.Logger
}
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
// 1. Acquire bus.lock (single-instance enforcement)
// 2. Write bus.meta
// 3. Listen IPC (so consumers can connect before SDK starts pushing)
// 4. Signal readiness via ReadyPipe
// 5. Start the Source (cloud SDK); concurrent with consumer accept loop
// 6. Wait on ctx for shutdown signal
// 7. Graceful: broadcast Bye → close listener → close source → release lock
//
// Run blocks until ctx is cancelled, the Source returns an error, or a fatal
// startup error occurs.
func Run(ctx context.Context, cfg Config) error {
if cfg.Source == nil {
return errors.New("bus: Source is required")
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
log := cfg.Logger.With("component", "bus", "client_id", cfg.ClientID, "edition", cfg.Edition)
// 1. Acquire bus.lock
if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: mkdir workdir: %w", err))
}
lockPath := filepath.Join(cfg.WorkDir, LockFileName)
lock, err := Acquire(lockPath)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: acquire lock: %w", err))
}
defer lock.Close()
// 2. Write bus.meta
meta := Meta{
ClientID: cfg.ClientID,
Edition: cfg.Edition,
SourceKind: cfg.SourceKind,
IdentityHash: cfg.IdentityHash,
SourceID: cfg.SourceID,
StartedAt: time.Now().UTC(),
SDKVersion: cfg.SDKVersion,
BusPID: os.Getpid(),
}
if err := WriteMeta(cfg.WorkDir, meta); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err))
}
// 3. IPC listen
listener, err := transport.Listen(cfg.IPCEndpoint)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: ipc listen: %w", err))
}
defer listener.Close()
hub := NewHub(cfg.ConsumerBuffer)
dd := dedup.NewWithCapacity(cfg.DedupCapacity)
d := &daemon{
cfg: cfg,
log: log,
lock: lock,
listener: listener,
hub: hub,
dedup: dd,
started: time.Now().UTC(),
idleStop: make(chan struct{}),
}
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
// scenario where the parent thinks bus is dead but it's actually mid-
// startup). Source.Start hasn't yet pulled events from the cloud, but
// any consumer that connects gets queued for the first events.
signalReady(cfg.ReadyPipe)
// runCtx is a child of the caller's ctx that ALL background goroutines
// (acceptLoop / idleWatch / dropWarnWatcher / source.Start) listen on.
// On idle-timeout shutdown the parent ctx is never cancelled, so we
// cancel runCtx ourselves before waiting for the goroutines — otherwise
// dropWarnWatcher (which only exits on ctx.Done) hangs forever.
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
// 5. Start accept loop and Source concurrently. runCtx cancellation
// propagates to both.
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
d.acceptLoop(runCtx)
}()
idleDone := make(chan struct{})
go func() {
defer close(idleDone)
d.idleWatch(runCtx)
}()
// Drop-rate WARN: per-event-type back-pressure monitoring. Runs in
// the background until runCtx cancellation; emits one WARN per scan
// when a type's drop rate first crosses the threshold (hysteresis
// suppresses repeats unless the rate jumps further).
dropWarnDone := make(chan struct{})
go func() {
defer close(dropWarnDone)
dropWarnWatcher(runCtx, hub.Counters(), log, cfg.DropWarnPercent)
}()
srcErr := make(chan error, 1)
go func() {
// emit is called from inside the SDK callback goroutine. It MUST
// NOT block (plan invariant #1) — dedup + Hub.Deliver are both
// non-blocking by construction.
emit := func(raw *dwsevent.RawEvent) {
if raw == nil {
return
}
if dd.Seen(raw.DedupKey()) {
return // duplicate redelivery from the cloud
}
hub.Deliver(raw)
}
srcErr <- cfg.Source.Start(runCtx, emit)
}()
// 6. Wait for shutdown trigger.
var exitErr error
select {
case <-ctx.Done():
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
case err := <-srcErr:
log.Error("bus: source exited", "err", err)
exitErr = err
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then close listener / drain consumers.
cancelRun()
d.shutdown()
<-acceptDone
<-idleDone
<-dropWarnDone
return exitErr
}
// daemon is the in-memory state of one bus run. Lifetime equals one Run() call.
type daemon struct {
cfg Config
log *slog.Logger
lock *Lock
listener transport.Listener
hub *Hub
dedup *dedup.LRU
started time.Time
consumerWG sync.WaitGroup // tracks live connection handler goroutines
conns sync.Map // map[net.Conn]struct{} for forced shutdown close
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
}
// acceptLoop drives the IPC accept goroutine. Each accepted connection is
// passed to handleConnection in its own goroutine; the accept loop returns
// when the listener Close()s (typically during shutdown).
func (d *daemon) acceptLoop(ctx context.Context) {
for {
conn, err := d.listener.Accept()
if err != nil {
if d.shuttingDown.Load() {
return
}
// Transient errors: log + continue. Non-transient (listener
// closed) shows up as net.ErrClosed → also a clean exit.
if errors.Is(err, net.ErrClosed) {
return
}
d.log.Warn("bus: accept error", "err", err)
continue
}
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
d.handleConnection(ctx, conn)
}()
}
}
// handleConnection processes one IPC connection's full lifecycle: read
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
d.conns.Store(conn, struct{}{})
defer func() {
d.conns.Delete(conn)
conn.Close()
}()
r := transport.NewReader(conn)
w := transport.NewWriter(conn)
// Expect Hello first.
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
d.log.Warn("bus: hello read failed", "err", err)
return
}
if hello.Type != transport.FrameTypeHello {
d.log.Warn("bus: first frame not hello", "type", hello.Type)
return
}
// Ad-hoc tooling (status/list/stop) — short-lived RPC, no Hub register.
if hello.Role == transport.HelloRoleStatus {
d.handleStatusRPC(w, r)
return
}
if hello.Role == transport.HelloRoleStop {
// Signal shutdown by cancelling our parent ctx via shutdown().
// For now, return after acking — daemon.shutdown is wired through
// daemon's exit path (the busctl/stop command sends SIGTERM in
// addition to this RPC for v1).
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "stop_request"})
go d.triggerShutdown("stop_request")
return
}
// Regular consumer registration
c, err := d.hub.Register(hello)
if err != nil {
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
return
}
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
idleSecs := int(d.cfg.IdleTimeout / time.Second)
if err := w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: idleSecs,
}); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
return
}
// Writer goroutine pulls from SendCh and writes to the wire.
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
for frame := range c.SendCh {
if err := w.WriteJSON(frame); err != nil {
// Wire error: peer dead. Returning here will let the
// reader goroutine notice EOF and Unregister.
return
}
}
}()
// Reader loop: wait for Bye or EOF. Both trigger Unregister.
for {
raw, err := r.Read()
if err != nil {
if !errors.Is(err, io.EOF) {
d.log.Debug("bus: consumer read error", "pid", c.PID, "err", err)
}
break
}
typ, err := transport.PeekType(raw)
if err != nil {
continue
}
if typ == transport.FrameTypeBye {
break
}
// Heartbeat / future client→bus frames: ignored for v1.
}
// Order matters: Unregister first (closes SendCh), then wait for the
// writer goroutine to drain. The reverse order would deadlock because
// the writer loops on `range SendCh` until close, but only the Hub
// can close that channel via Unregister.
d.hub.Unregister(c.ID)
<-writerDone
_ = ctx // for future use (writer ctx-cancel propagation)
}
// handleStatusRPC services a single status_req and returns. The connection
// is closed by the caller's defer.
func (d *daemon) handleStatusRPC(w *transport.Writer, r *transport.Reader) {
var req transport.StatusReq
if err := r.ReadJSON(&req); err != nil {
return
}
resp := transport.StatusResp{
Type: transport.FrameTypeStatusResp,
Bus: transport.StatusBus{
PID: os.Getpid(),
UptimeSecs: int64(time.Since(d.started).Seconds()),
IdleTimeoutSec: int(d.cfg.IdleTimeout / time.Second),
ClientID: d.cfg.ClientID,
Edition: d.cfg.Edition,
SourceKind: d.cfg.SourceKind,
IdentityHash: d.cfg.IdentityHash,
SourceID: d.cfg.SourceID,
},
SourceState: transport.StatusSource{
State: "connected", // v1: source state plumbed in P3+
Source: "inferred",
},
Consumers: d.hub.Snapshot(),
PerEventTypeCounters: d.hub.Counters().Snapshot(),
}
_ = w.WriteJSON(resp)
}
// idleWatch fires d.idleStop when IdleTimeout passes with zero registered
// consumers. Disabled when IdleTimeout <= 0 (returns immediately; idleStop
// is then never closed and Run's select branch on it is effectively dead).
//
// Pre-condition: d.idleStop has already been allocated by Run so the parent
// select never races on a nil channel (which would block forever).
func (d *daemon) idleWatch(ctx context.Context) {
if d.cfg.IdleTimeout <= 0 {
return
}
tick := time.NewTicker(d.cfg.IdleTimeout / 4)
defer tick.Stop()
emptySince := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if d.hub.Len() == 0 {
if emptySince.IsZero() {
emptySince = time.Now()
} else if time.Since(emptySince) >= d.cfg.IdleTimeout {
close(d.idleStop)
return
}
} else {
emptySince = time.Time{}
}
}
}
}
// triggerShutdown is called from RPC handlers that want to end the bus.
// It works by closing the listener (which unblocks Run's select via the
// source error path, indirectly). For v1 a full ctx-cancellation hook is
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
// shutdown path.
func (d *daemon) triggerShutdown(reason string) {
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
// Best-effort: a future version wires a context.CancelFunc here.
}
// shutdown performs the graceful tear-down sequence:
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for all per-connection goroutines to drain
// 5. lock + meta cleanup via Run's defers
func (d *daemon) shutdown() {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
return
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
_ = d.listener.Close()
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
// Give consumers a brief moment to drain final frames before we tear
// down their channels.
doneCh := make(chan struct{})
go func() {
d.consumerWG.Wait()
close(doneCh)
}()
select {
case <-doneCh:
case <-time.After(2 * time.Second):
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
}
}
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
func signalReady(p *os.File) {
if p == nil {
return
}
_, _ = p.Write([]byte{'R'})
_ = p.Close()
}
// failReady writes 'E' to the ready pipe (if provided) and returns err.
// Used by the startup-failure paths so the parent can distinguish "still
// starting up" from "failed to start".
func failReady(p *os.File, err error) error {
if p != nil {
_, _ = p.Write([]byte{'E'})
_ = p.Close()
}
return err
}

Some files were not shown because too many files have changed in this diff Show More