Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba94c0092 | ||
|
|
fe4a79283c | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 | ||
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e | ||
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf | ||
|
|
a0b956a780 | ||
|
|
5e4d974039 | ||
|
|
bb641c2098 | ||
|
|
7e1e93478b | ||
|
|
101e7be98d | ||
|
|
9545c1dde5 | ||
|
|
81367b2861 | ||
|
|
97bab33c71 | ||
|
|
ef6d65087f | ||
|
|
30a024e615 | ||
|
|
00e5ce7367 | ||
|
|
760c40dd71 | ||
|
|
aaa2d7d3be | ||
|
|
fbfcd69c2b | ||
|
|
fab9e5be52 | ||
|
|
ad056a8d83 | ||
|
|
5e254b4745 | ||
|
|
29a8a14760 | ||
|
|
00bef0a809 | ||
|
|
3f5b2fa8d3 | ||
|
|
b0e7b58e95 | ||
|
|
fe46cd4dc2 | ||
|
|
c993086d9d | ||
|
|
f522a9c2c2 | ||
|
|
d89649f9bb | ||
|
|
f218a05ead | ||
|
|
62aef1cc96 | ||
|
|
fc5e4d0d8d | ||
|
|
34248fabf3 | ||
|
|
430d20f2ee | ||
|
|
ada4acc395 | ||
|
|
8bebd77dfa | ||
|
|
b096fa06db | ||
|
|
107f3eaf6e | ||
|
|
df0c0f7545 | ||
|
|
91e67e2e45 | ||
|
|
75468dca1e | ||
|
|
ef5c05a2e6 | ||
|
|
1ac8636418 | ||
|
|
86086437d8 | ||
|
|
d432029d84 | ||
|
|
029bfdd2ed | ||
|
|
8c0551f359 | ||
|
|
b839ee664e | ||
|
|
c16c4aa59a | ||
|
|
aecc0bc588 | ||
|
|
cfad3bbf4a | ||
|
|
40736e2ae1 | ||
|
|
9aeb60da37 | ||
|
|
d3fcd814f3 | ||
|
|
5ed69744cc | ||
|
|
e5c9c91342 | ||
|
|
0e59fedbbb | ||
|
|
c39378864d | ||
|
|
7cb33e970c | ||
|
|
567cf163f1 | ||
|
|
d9ae15f9a6 | ||
|
|
922745f318 | ||
|
|
58dced0c8a | ||
|
|
3427b65da6 | ||
|
|
9d38a3be54 | ||
|
|
1cda263e0e | ||
|
|
5d5884a0d7 | ||
|
|
4a26f1ebd2 | ||
|
|
b65be61599 | ||
|
|
e193ba97f4 | ||
|
|
787a40ffcb | ||
|
|
f69d9ed281 | ||
|
|
f2b2de89f4 | ||
|
|
7915a5a4e1 | ||
|
|
6067906c55 | ||
|
|
493ca36e08 | ||
|
|
518b1cb631 | ||
|
|
f29655e7e4 | ||
|
|
cc8a726b0a | ||
|
|
f0552dd20e | ||
|
|
a040b57be7 | ||
|
|
a2e8700beb | ||
|
|
98804801c9 | ||
|
|
6946dd1e35 | ||
|
|
d09fca4b9b | ||
|
|
70d66daa5f | ||
|
|
1926bd17b9 | ||
|
|
6a392e611c | ||
|
|
710844382e | ||
|
|
b1a88106e5 | ||
|
|
dcf132a7e3 | ||
|
|
d2fd7e00b5 | ||
|
|
7720e3ec9b | ||
|
|
537719c677 | ||
|
|
51db546686 | ||
|
|
e9d50a659f | ||
|
|
2264743e78 | ||
|
|
6244222adf | ||
|
|
a31a29e0ab | ||
|
|
3ec35e30e8 | ||
|
|
da08e84e80 | ||
|
|
5e1983b3df | ||
|
|
8984f7b8be | ||
|
|
dd08db54d8 | ||
|
|
591609fdee | ||
|
|
6447bdd45f | ||
|
|
2610c8ce87 | ||
|
|
0e6cb46f58 | ||
|
|
a613728fbd | ||
|
|
70b9f39715 | ||
|
|
d29aebeecd | ||
|
|
844059a77b | ||
|
|
9ee8ea5524 | ||
|
|
42c71c9c54 | ||
|
|
690d0788e3 | ||
|
|
5a7c0748e3 | ||
|
|
410949cdf3 | ||
|
|
0197dbc81a | ||
|
|
b85353e042 | ||
|
|
f740955423 | ||
|
|
166f665708 | ||
|
|
55f5e0c3d0 | ||
|
|
f58b426381 | ||
|
|
2640338803 | ||
|
|
dbcbb1de85 | ||
|
|
63b6112fa2 | ||
|
|
eaeb9ac05d | ||
|
|
e82ccd3496 | ||
|
|
b4c9db8807 | ||
|
|
aa76f01015 | ||
|
|
931d7e59ef | ||
|
|
db2043c82b | ||
|
|
c2f3653ec4 | ||
|
|
11dfd4ccf2 | ||
|
|
ef60d99b26 | ||
|
|
1b6e197426 | ||
|
|
41b743de77 | ||
|
|
caf672699f | ||
|
|
67ac777657 | ||
|
|
c9ba0373c2 | ||
|
|
4a19530fd0 | ||
|
|
7a28d97739 | ||
|
|
d63f7e5836 | ||
|
|
8aaf0fb6e8 | ||
|
|
0f3c4ccbdd | ||
|
|
1f21fdf7be | ||
|
|
b2fd204e67 | ||
|
|
6f8c9173d3 | ||
|
|
1921e5e37e | ||
|
|
6480c035fa | ||
|
|
1c88dd6a0d | ||
|
|
6ce4a635b2 | ||
|
|
ee2fa735b2 | ||
|
|
a9df88654d | ||
|
|
e58e805b17 | ||
|
|
d1d6d9e74c | ||
|
|
b75f07547f | ||
|
|
ccb4927c48 | ||
|
|
1a2454a5ab | ||
|
|
0a8de62041 | ||
|
|
d6f44143ad | ||
|
|
4d831e5054 | ||
|
|
2c51774150 | ||
|
|
a10169344f | ||
|
|
82d625603f | ||
|
|
11780ae3b0 | ||
|
|
79bab762bf | ||
|
|
e840cac3cc | ||
|
|
45b4f088af | ||
|
|
b28ca6364e | ||
|
|
8b39dac5db | ||
|
|
7ecfe85696 | ||
|
|
9af7f9a034 | ||
|
|
c50494775c | ||
|
|
1fe4842525 | ||
|
|
5708e1c2d2 | ||
|
|
d43ec228aa | ||
|
|
fb335fb403 | ||
|
|
fd61526707 | ||
|
|
c858241b68 | ||
|
|
b826440995 | ||
|
|
25f69b867e | ||
|
|
172060b868 | ||
|
|
dd0990691e | ||
|
|
02bc7ce880 | ||
|
|
4d476ed317 | ||
|
|
ff89ffed69 | ||
|
|
65ab1a3076 | ||
|
|
cf277f3c8f | ||
|
|
59100407dc | ||
|
|
10de987e81 | ||
|
|
c36fcaaf70 | ||
|
|
0cf97cea55 | ||
|
|
47354b918a | ||
|
|
df1e33442b | ||
|
|
a7ca1e1a5b | ||
|
|
211a06c05a | ||
|
|
bb52a505dc | ||
|
|
f899f4f6b6 | ||
|
|
3adeffb09d | ||
|
|
435bf3151c | ||
|
|
1c90edb92a | ||
|
|
36b89a04b1 | ||
|
|
9d3980f90a | ||
|
|
ae5ef70bb8 | ||
|
|
89c3aba2ed | ||
|
|
c151756168 | ||
|
|
473d2f9aaf | ||
|
|
3dcb40c634 | ||
|
|
5e4b5e4eda | ||
|
|
d873b9c017 | ||
|
|
4b8acc8e73 | ||
|
|
0f65cb0064 | ||
|
|
f013536aac | ||
|
|
c56eb7f8c2 | ||
|
|
f9abc79ecc | ||
|
|
99893e473a | ||
|
|
51b3316187 | ||
|
|
9f744caafd | ||
|
|
532fcb4874 | ||
|
|
28d556c5e9 | ||
|
|
c106de8361 | ||
|
|
2e11a23381 | ||
|
|
2c1be7a8fb | ||
|
|
e637d793b0 | ||
|
|
864f94e80e | ||
|
|
7f1d36c9ac | ||
|
|
4eea61897d | ||
|
|
c99ac87839 | ||
|
|
1b3319603d | ||
|
|
995d490a76 | ||
|
|
5a401ddc1b | ||
|
|
f5bdb7118a | ||
|
|
4c86a9f8e1 | ||
|
|
3117ad5d86 | ||
|
|
0e484bb189 | ||
|
|
de4a292174 | ||
|
|
ad43e1ba28 | ||
|
|
ac825ad293 | ||
|
|
8411211a8c | ||
|
|
449d731239 | ||
|
|
13d77ee7b7 | ||
|
|
694288cfbc | ||
|
|
6f5a0afdf7 | ||
|
|
248c6602ac | ||
|
|
9589c23796 | ||
|
|
9460437c9a | ||
|
|
34d691906e | ||
|
|
a78765bb42 | ||
|
|
836f5bdb25 | ||
|
|
3282957958 | ||
|
|
252c61aff6 | ||
|
|
ffefc53861 | ||
|
|
8cbc56edd2 | ||
|
|
fc0873b0c6 | ||
|
|
eea85bd989 | ||
|
|
43560afa78 | ||
|
|
7ef46c1288 | ||
|
|
08413a6903 | ||
|
|
c488421423 | ||
|
|
b3e8783c4f | ||
|
|
1c2c96617a | ||
|
|
5fe366eddd | ||
|
|
a7d109384b | ||
|
|
9e7e7dbc7f | ||
|
|
7652bda320 | ||
|
|
dd1c4e34fd | ||
|
|
e653616aa3 | ||
|
|
3ce64db2fc | ||
|
|
4bbd52fc58 | ||
|
|
9eb3099881 | ||
|
|
73e2de7fe8 | ||
|
|
8b4f05e44e | ||
|
|
a7879edca1 | ||
|
|
3f09eb5c0f | ||
|
|
202cb509a0 | ||
|
|
dd42fd833b | ||
|
|
b5f241c719 | ||
|
|
4cdc3e7320 | ||
|
|
b558bda6df | ||
|
|
1063a6425d | ||
|
|
0a8517432f | ||
|
|
83bfca8d35 | ||
|
|
9e58a062f8 | ||
|
|
aa06d9d5c9 | ||
|
|
bb3d1270b9 | ||
|
|
7d40210a00 | ||
|
|
ab445813b7 | ||
|
|
631a3829e4 | ||
|
|
88b4f4eeaa | ||
|
|
ee8af0c7d0 | ||
|
|
4f3501e904 | ||
|
|
b0108b9c21 | ||
|
|
4dc79fc931 | ||
|
|
130387eb36 | ||
|
|
0c2b2b4703 | ||
|
|
fed83761e5 | ||
|
|
55eb481899 | ||
|
|
eb3eecab1d | ||
|
|
3afbc046d8 | ||
|
|
738b64eaf4 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 57.5%"><title>coverage: 57.5%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">57.5%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">57.5%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">57.5%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -31,14 +31,45 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Multi Profile E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: multi-profile-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run isolated multi-profile chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload debug artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: multi-profile-e2e-debug
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -12,7 +12,10 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -28,6 +31,9 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -58,6 +64,18 @@ jobs:
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-oss.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
@@ -75,6 +93,8 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
@@ -26,6 +26,7 @@ test/cli_compat/testdata/
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
!skills/**/credentials.md
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
@@ -34,3 +35,10 @@ dws.zip
|
||||
|
||||
# envelope/discovery.pre.json synced via Portal, not git-tracked
|
||||
/envelope/discovery.pre.json
|
||||
|
||||
# local/pre-release MCP service configs may contain personal gateway keys
|
||||
/docs/mcp/serviceconfig-pre*
|
||||
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ changelog:
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: DingTalk-Real-AI
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -6,6 +6,95 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.46] - 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
|
||||
|
||||
## [1.0.45] - 2026-06-29
|
||||
|
||||
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
|
||||
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
|
||||
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
|
||||
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
|
||||
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
|
||||
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
|
||||
|
||||
## [1.0.44] - 2026-06-28
|
||||
|
||||
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
|
||||
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
|
||||
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
|
||||
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
|
||||
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
|
||||
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
|
||||
|
||||
## [1.0.43] - 2026-06-26
|
||||
|
||||
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
|
||||
|
||||
### Added
|
||||
|
||||
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
|
||||
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
|
||||
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
|
||||
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
|
||||
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
|
||||
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
|
||||
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
|
||||
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
|
||||
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
|
||||
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
|
||||
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
|
||||
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws devapp robot connect` — generic `custom` channel for self-built / unsupported AI tools** (issue #37; `internal/helpers/devapp_connect.go`, `internal/helpers/connect_stream.go`) — a new `--agent-cmd "<command>"` flag (and `custom` channel) lets the bot forward to any headless AI CLI that takes a question as its trailing argument and prints the answer to stdout, so tools that aren't built-in (e.g. 网易有道龙虾 LobsterAI) or self-built agents can be onboarded without code changes. `--agent-cmd` forces the `custom` channel unless `--channel` is set explicitly; detection also falls back to `custom` when `DWS_AGENT_CMD` is present.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`robot connect` now hints how to match terminal answer quality** (issue #39; `internal/helpers/devapp_connect.go`) — when neither a work dir nor a knowledge source is configured, the connector prints a one-time note that the bot runs in a clean temp dir without local project context, pointing at `--agent-workdir` / `--knowledge-dir` / `--knowledge-source` / `--agent-model`. The robot quickstart gains matching FAQ entries, plus a clarification that step 3 (`robot connect`) produces no approval ticket (issue #19).
|
||||
|
||||
- **`robot connect` session commands `/new` vs `/clear` now use each channel's real session op** (PR #20; `internal/helpers/connect_opencode.go`, `internal/helpers/connect_stream.go`) — `/new` (and `/start`, `/reset`) opens a fresh session and leaves the previous one intact (resumable where the agent supports it); `/clear` actively disposes the current session through the agent's real delete primitive — opencode issues `DELETE /session/:id`. Channels whose agent exposes no delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude-family exec) fall back to a reset, so `/clear` behaves like `/new` there. Previously both commands only dropped the local `conversationId → sessionId` mapping, so the two were indistinguishable and opencode sessions were never disposed (they leaked).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`robot connect` no longer aborts long opencode turns at 30 seconds** (PR #19; `internal/helpers/connect_opencode.go`) — the shared opencode HTTP client hard-coded a 30s `Timeout` that covered every request, including `POST /session/{id}/message`, so a long agent turn (e.g. a multi-minute research report) was killed mid-flight with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` even though the per-turn budget (`DWS_AGENT_TIMEOUT_MS`, default 300s) was far larger. The client-level deadline is removed so the per-request ctx governs the round-trip; only the `/global/health` probe keeps a short 10s timeout so startup detection stays snappy.
|
||||
|
||||
## [1.0.41] - 2026-06-24
|
||||
|
||||
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
|
||||
|
||||
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -225,6 +233,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
|
||||
|
||||
```bash
|
||||
dws auth login # log in to another org → adds a profile (first login becomes the primary)
|
||||
dws profile list # list logged-in orgs (primary / current marker, status)
|
||||
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
|
||||
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
|
||||
```
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
|
||||
|
||||
@@ -299,7 +323,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/`, ... 18 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/`, ... 20 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:
|
||||
|
||||
@@ -309,6 +333,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
@@ -495,31 +521,48 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</details>
|
||||
|
||||
## DingTalk bot — connect a robot to your local AI
|
||||
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
|
||||
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
|
||||
|
||||
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
|
||||
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
|
||||
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
|
||||
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates |
|
||||
| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
|
||||
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
|
||||
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
|
||||
| Todo | `todo` | Create / list / update / complete tasks and comments |
|
||||
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
|
||||
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
|
||||
| Ding | `ding` | Send / recall DING messages |
|
||||
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
|
||||
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
|
||||
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
|
||||
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
|
||||
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
|
||||
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
|
||||
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
|
||||
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
|
||||
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
|
||||
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
|
||||
| Live | `live` | List my live streams |
|
||||
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
|
||||
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
@@ -579,6 +622,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [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
|
||||
- [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
|
||||
|
||||
## Contributing
|
||||
|
||||
+68
-24
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -225,6 +233,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
|
||||
|
||||
```bash
|
||||
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
|
||||
dws profile list # 列出已登录组织(主 / 当前标记、状态)
|
||||
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
|
||||
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
|
||||
```
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
|
||||
|
||||
@@ -306,6 +330,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
@@ -491,31 +517,48 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</details>
|
||||
|
||||
## 钉钉机器人 —— 把机器人接到你本地的 AI
|
||||
|
||||
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
|
||||
|
||||
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
|
||||
| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
|
||||
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
|
||||
| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
|
||||
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
|
||||
| DING | `ding` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
|
||||
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
|
||||
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
|
||||
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
|
||||
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
|
||||
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
|
||||
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
|
||||
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
|
||||
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
|
||||
| 直播 | `live` | 查看我的直播列表 |
|
||||
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
|
||||
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
@@ -577,6 +620,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
## 贡献指南
|
||||
|
||||
@@ -43,6 +43,8 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
|
||||
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ warehouse. This page is the integration contract.
|
||||
|
||||
| Header | Meaning | Granularity |
|
||||
|--------|---------|-------------|
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
|
||||
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
|
||||
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
|
||||
@@ -26,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as
|
||||
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
|
||||
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
|
||||
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
|
||||
5. **T4 — `custom`:** unknown host. Never guessed.
|
||||
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
@@ -55,8 +55,8 @@ MCP server config example (JSON-style hosts):
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
|
||||
verbatim so PAT grants and follow-up command checks use the same key.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ _Search the DingTalk Open Platform documentation._
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, traceId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
|
||||
|
||||
## `dws ding` — DING Messages
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
|
||||
Stream long-connection. By default it runs in the foreground and dies when the
|
||||
terminal closes. For an unattended "digital employee" you have two options.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws devapp robot connect --daemon \
|
||||
--channel claudecode \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
|
||||
# Inspect / stop it.
|
||||
dws devapp robot connect status --robot-client-id <clientId>
|
||||
dws devapp robot connect stop --robot-client-id <clientId>
|
||||
```
|
||||
|
||||
- The parent prints the daemon pid and the log path, then exits.
|
||||
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
|
||||
worker connector alive, restarting it with exponential backoff (1s..60s, up to
|
||||
10 consecutive fast failures) when it exits abnormally.
|
||||
- The single-instance lock (one connector per robot per machine) is reused, so a
|
||||
duplicate daemon refuses to start.
|
||||
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<clientId>/daemon.pid`.
|
||||
- The daemon does NOT survive a reboot. For that, use Option B.
|
||||
|
||||
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
|
||||
> Windows service wrapper around the foreground command instead.
|
||||
|
||||
## Option B: OS service manager (survives reboot)
|
||||
|
||||
Use the foreground command (NOT `--daemon`) and let the OS supervise and
|
||||
restart it. This is the most robust way to get boot-time auto-start.
|
||||
|
||||
### macOS — launchd
|
||||
|
||||
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
|
||||
and credentials, then `launchctl load -w <path>`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.dingtalk.dws.connect</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/dws</string>
|
||||
<string>devapp</string>
|
||||
<string>robot</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--robot-client-id</string>
|
||||
<string>REPLACE_CLIENT_ID</string>
|
||||
<string>--robot-client-secret</string>
|
||||
<string>REPLACE_CLIENT_SECRET</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/dws-connect.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/dws-connect.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
|
||||
itself relies on the single-instance lock to avoid duplicates.
|
||||
|
||||
### Linux — systemd (user service)
|
||||
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
|
||||
then:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dws-connect.service
|
||||
# allow it to keep running after logout:
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DWS DingTalk robot connector
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/dws devapp robot connect \
|
||||
--channel claudecode \
|
||||
--robot-client-id REPLACE_CLIENT_ID \
|
||||
--robot-client-secret REPLACE_CLIENT_SECRET
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Optional hardening:
|
||||
# NoNewPrivileges=true
|
||||
# PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
|
||||
stderr into the journal (`journalctl --user -u dws-connect -f`).
|
||||
|
||||
## Which to choose
|
||||
|
||||
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
|
||||
- Need it to come back after a reboot, with the OS owning the lifecycle → use
|
||||
launchd / systemd with the foreground command.
|
||||
@@ -0,0 +1,137 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
## 手工评测流程
|
||||
|
||||
逐条执行,每条三步:
|
||||
|
||||
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
|
||||
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
|
||||
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
|
||||
|
||||
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
|
||||
|
||||
## 覆盖矩阵
|
||||
|
||||
| 用例 | 覆盖的子命令 | 横切行为 |
|
||||
|------|-------------|---------|
|
||||
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
|
||||
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
|
||||
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
|
||||
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
|
||||
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
|
||||
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
|
||||
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
|
||||
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
|
||||
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
|
||||
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
|
||||
|
||||
---
|
||||
|
||||
## 用例
|
||||
|
||||
### C1. 新建应用并配齐基础
|
||||
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
|
||||
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
|
||||
2. `app get --unified-app-id <id> --format json` 看详情。
|
||||
3. `credentials get --unified-app-id <id> --format json` 取凭证。
|
||||
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
|
||||
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
|
||||
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
|
||||
|
||||
### C2. 应用列表与按名定位
|
||||
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
|
||||
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
|
||||
- **期望(分步)**:
|
||||
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
|
||||
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
|
||||
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
|
||||
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
|
||||
|
||||
### C3. 应用生命周期(停用 / 启用 / 删除)
|
||||
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
|
||||
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
|
||||
- **期望(分步)**:
|
||||
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
|
||||
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
|
||||
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
|
||||
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
|
||||
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
|
||||
|
||||
### C4. 网页应用配置到生效
|
||||
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
|
||||
- **覆盖**:`webapp config` / `get`;生效模型。
|
||||
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
|
||||
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
|
||||
- **易错点**:配完直接说已生效,不提版本通道。
|
||||
|
||||
### C5. 版本发布全流程(含选审批人)
|
||||
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
|
||||
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
|
||||
- **期望(分步)**:
|
||||
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
|
||||
2. `version list` 看历史、`version get --version-id <id>` 看详情。
|
||||
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
|
||||
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
|
||||
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
|
||||
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
|
||||
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
|
||||
|
||||
### C6. 权限全流程(查 / 申请 / 批量取消)
|
||||
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
|
||||
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
|
||||
- **期望(分步)**:
|
||||
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
|
||||
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
|
||||
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
|
||||
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
|
||||
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
|
||||
|
||||
### C7. 成员与安全配置
|
||||
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
|
||||
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
|
||||
- **期望(分步)**:
|
||||
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
|
||||
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
|
||||
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
|
||||
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
|
||||
|
||||
### C8. 机器人建号、配置与本地建联
|
||||
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
|
||||
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
|
||||
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
|
||||
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
|
||||
|
||||
### C9. 事件订阅与上游错误排查
|
||||
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
|
||||
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
|
||||
- **期望(分步)**:
|
||||
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
|
||||
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
|
||||
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
|
||||
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
|
||||
|
||||
### C10. 意图消歧(泛词边界)
|
||||
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
|
||||
- **覆盖**:泛词消歧、边界与角色。
|
||||
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
|
||||
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
|
||||
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
|
||||
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
|
||||
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
|
||||
@@ -0,0 +1,321 @@
|
||||
# dws dev 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
### Windows(PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
### 国内加速
|
||||
|
||||
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
## 安装后验证
|
||||
|
||||
先确认 `dws` 可执行:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 `dws dev app` 命令存在:
|
||||
|
||||
```bash
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
```
|
||||
|
||||
如果尚未登录:
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws dev app list --format json
|
||||
```
|
||||
|
||||
## dws dev 是什么
|
||||
|
||||
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
|
||||
|
||||
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
|
||||
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
|
||||
- `dws dev doc` — 开放平台开发文档搜索。
|
||||
|
||||
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
- 配置网页应用首页和管理后台地址。
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 异步创建机器人、配置/启停现有机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 `dws dev`。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
帮我查一下最近创建的开放平台应用。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先预检是否需要审批。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
|
||||
1. 先查询定位应用。
|
||||
2. 先 dry-run 预览。
|
||||
3. 明确展示将要修改的应用、字段和值。
|
||||
4. 用户确认后加 `--yes` 执行。
|
||||
5. 执行后回读验证。
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
|
||||
|
||||
提交建号任务(记下返回的 `taskId`):
|
||||
|
||||
```bash
|
||||
dws dev app robot submit \
|
||||
--name "告警助手" \
|
||||
--robot-name "告警机器人" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
|
||||
|
||||
```bash
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
```
|
||||
|
||||
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws dev app list --format json
|
||||
dws dev app get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app create --name "考勤应用" --dry-run --format json
|
||||
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
|
||||
```
|
||||
|
||||
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws dev app permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人能力
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws dev app member list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 网页应用与事件
|
||||
|
||||
```bash
|
||||
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
|
||||
dws dev app event list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws dev app version list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws dev app` 不存在
|
||||
|
||||
先确认装上的是带 `dws dev` 的版本:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
|
||||
|
||||
### `dws dev app list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
dws auth login
|
||||
```
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 提示"当前用户没有开发者身份"
|
||||
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
- 应用开发者。
|
||||
- 企业管理员或具备开放平台应用管理权限的角色。
|
||||
|
||||
### 机器人配置失败
|
||||
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install dws dev in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
and publish versions with dry-run safety built in.
|
||||
```
|
||||
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 dws dev。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
所有写操作先预览,再确认执行。
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- Notion Developer Platform: https://www.notion.com/product/dev
|
||||
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
|
||||
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
|
||||
@@ -0,0 +1,147 @@
|
||||
# 钉钉 AI 群机器人快速上手
|
||||
|
||||
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
|
||||
|
||||
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
> ```bash
|
||||
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
> ```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
按提示扫码登录即可。
|
||||
|
||||
## 第二步:创建机器人
|
||||
|
||||
建号是异步的,两步(名字、描述可以改成你自己的):
|
||||
|
||||
```bash
|
||||
# 1) 提交建号任务,记下返回的 taskId
|
||||
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
|
||||
|
||||
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
|
||||
dws dev app robot result --task-id 上一步返回的taskId --format json
|
||||
```
|
||||
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
|
||||
```
|
||||
|
||||
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
|
||||
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
|
||||
|
||||
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
|
||||
|
||||
## 进阶配置(可选)
|
||||
|
||||
按需加在第三步的命令后面:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
|
||||
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
|
||||
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
|
||||
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
|
||||
| `--allowed-groups 群ID` | 群白名单 |
|
||||
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
|
||||
|
||||
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
|
||||
|
||||
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
|
||||
|
||||
```bash
|
||||
dws dev connect \
|
||||
--agent-cmd "你的AI命令 一次性问答参数" \
|
||||
--robot-client-id dingxxxx --robot-client-secret yyyy
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**执行命令报 `zsh: parse error near '\n'`?**
|
||||
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
|
||||
|
||||
**群里 @机器人 没反应?**
|
||||
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
|
||||
|
||||
**第二步提示"当前用户没有开发者身份"?**
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
|
||||
|
||||
**提示找不到 dws 命令?**
|
||||
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
|
||||
|
||||
**提示本地没有装 AI 工具?**
|
||||
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
|
||||
|
||||
**机器人回复"调用失败"?**
|
||||
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
|
||||
|
||||
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
|
||||
这通常不是模型问题,而是"机器人看到的上下文比终端少":
|
||||
|
||||
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
|
||||
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
|
||||
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
|
||||
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
|
||||
|
||||
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
|
||||
|
||||
## 会话指令:`/new` 和 `/clear`
|
||||
|
||||
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | **清空当前会话**:彻底从头开始 |
|
||||
|
||||
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
|
||||
|
||||
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
|
||||
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
|
||||
@@ -3,9 +3,15 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -14,15 +20,11 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
@@ -33,6 +35,7 @@ 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
|
||||
@@ -42,7 +45,6 @@ require (
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
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=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
@@ -20,15 +24,27 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -43,6 +59,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
|
||||
@@ -68,6 +86,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/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=
|
||||
@@ -88,6 +108,8 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
+679
-60
@@ -30,18 +30,40 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
func buildAuthCommand() *cobra.Command {
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
authLoginGuideDirectCLI authLoginGuideAction = "direct_cli"
|
||||
authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app"
|
||||
authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"}
|
||||
authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"}
|
||||
authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"}
|
||||
authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"}
|
||||
authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"}
|
||||
)
|
||||
|
||||
func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "认证管理",
|
||||
@@ -55,7 +77,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
cmd.AddCommand(newAuthLoginCommand(patCaller))
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
@@ -68,7 +90,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthLoginCommand() *cobra.Command {
|
||||
func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||||
@@ -88,9 +110,11 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -100,6 +124,10 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend)
|
||||
recommendAuthMode := cfg.Recommend || postLoginTUIMode
|
||||
humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode)
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Token) != "":
|
||||
@@ -128,8 +156,9 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||||
}
|
||||
@@ -137,43 +166,100 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
|
||||
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
if postLoginTUIMode {
|
||||
var planErr error
|
||||
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
|
||||
if planErr != nil {
|
||||
return planErr
|
||||
}
|
||||
if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项")
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
recommendScopeMode, err = loginRecommendScopeModeSelector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan}
|
||||
if postLoginTUIMode {
|
||||
opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
return loginRecommendProductSelector(products)
|
||||
}
|
||||
}
|
||||
retryFormat := format
|
||||
if humanAuthMode {
|
||||
retryFormat = "table"
|
||||
}
|
||||
run := func(ctx context.Context) error {
|
||||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||
}
|
||||
err := run(cmd.Context())
|
||||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||||
return runDirectPATAuthCheckWaitOnly(
|
||||
cmd.Context(),
|
||||
&GlobalFlags{Format: retryFormat},
|
||||
patErr,
|
||||
cmd.ErrOrStderr(),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode {
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
|
||||
}
|
||||
|
||||
// Default table output
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintf(w, "[OK] 登录成功!\n")
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID))
|
||||
}
|
||||
if tokenData.UserName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName))
|
||||
}
|
||||
if expiry := authLoginDisplayExpiry(tokenData); expiry != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry)
|
||||
fmt.Fprintln(w, authLoginInfoLine("有效期", expiry))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n")
|
||||
fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
|
||||
@@ -191,59 +277,171 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
var (
|
||||
authLoginGuideActionSelector = selectAuthLoginGuideAction
|
||||
authLoginGuideActionApplier = applyAuthLoginGuideAction
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
choice := authLoginGuideDirectCLI
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[authLoginGuideAction]().
|
||||
Title("选择操作").
|
||||
Options(
|
||||
huh.NewOption("直接使用CLI", authLoginGuideDirectCLI),
|
||||
huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp),
|
||||
huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error {
|
||||
switch action {
|
||||
case authLoginGuideDirectCLI:
|
||||
return nil
|
||||
case authLoginGuideConfigureAgentApp:
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
|
||||
return nil
|
||||
case authLoginGuideManualCredentials:
|
||||
clientID, clientSecret, err := promptAuthLoginManualCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authpkg.SetClientID(clientID)
|
||||
authpkg.SetClientSecret(clientSecret)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: clientID,
|
||||
ClientSecret: authpkg.PlainSecret(clientSecret),
|
||||
}); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func promptAuthLoginManualCredentials() (string, string, error) {
|
||||
var clientID, clientSecret string
|
||||
nonEmpty := func(label string) func(string) error {
|
||||
return func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s 不能为空", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("输入 AppKey").
|
||||
Value(&clientID).
|
||||
Validate(nonEmpty("AppKey")),
|
||||
huh.NewInput().
|
||||
Title("输入 AppSecret").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&clientSecret).
|
||||
Validate(nonEmpty("AppSecret")),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
||||
}
|
||||
|
||||
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
choice := pat.LoginRecommendScopeRecommended
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[pat.LoginRecommendScopeMode]().
|
||||
Title("选择授权范围").
|
||||
Description("空格选择 回车确认").
|
||||
Options(
|
||||
huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended),
|
||||
huh.NewOption("全部授权", pat.LoginRecommendScopeAll),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息",
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息(默认退出所有组织)",
|
||||
Long: `清除本机钉钉登录态。
|
||||
|
||||
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
|
||||
Example: ` dws auth logout
|
||||
dws auth logout --profile <corpId>
|
||||
dws auth logout --profile "钉钉"`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = authpkg.RevokeTokenRemote(revokeCtx)
|
||||
|
||||
// Load token data to get associated clientId before deletion
|
||||
var storedClientID string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
storedClientID = tokenData.ClientID
|
||||
if strings.TrimSpace(profileSelector) != "" {
|
||||
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret and app token from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||||
}
|
||||
// Also try cleaning app token using appKey from app config
|
||||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||||
_ = authpkg.DeleteAppTokenData(appKey)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token.json"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
fmt.Fprintln(w, "[OK] 已清除认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
Long: `查看当前或指定组织 profile 的认证状态。
|
||||
|
||||
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
|
||||
Example: ` dws auth status
|
||||
dws auth status --profile <corpId>
|
||||
dws auth status --profile "钉钉"
|
||||
dws auth status --profile <corpId> --format json`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
|
||||
authenticated := false
|
||||
refreshed := false
|
||||
@@ -263,6 +461,8 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -286,6 +486,12 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
}
|
||||
if tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||||
} else {
|
||||
@@ -298,12 +504,80 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(selector)
|
||||
defer restoreProfile()
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
restoreProfile()
|
||||
}
|
||||
}
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRuntimeProfile(selector string) func() {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return func() {}
|
||||
}
|
||||
previous := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile(selector)
|
||||
return func() {
|
||||
authpkg.SetRuntimeProfile(previous)
|
||||
}
|
||||
}
|
||||
|
||||
func 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 {
|
||||
@@ -338,7 +612,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
))
|
||||
}
|
||||
if !authpkg.PortableAuthSourceReady() {
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login")
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
@@ -502,17 +776,18 @@ func newAuthResetCommand() *cobra.Command {
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -553,6 +828,205 @@ func authLoginDisplayExpiry(data *authpkg.TokenData) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
if len(products) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
selected := make([]string, 0, len(products))
|
||||
options := make([]huh.Option[string], 0, len(products))
|
||||
for _, product := range products {
|
||||
code := strings.TrimSpace(product.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, code)
|
||||
options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true))
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
height := len(options)
|
||||
if height > 15 {
|
||||
height = 15
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("选择要授权的业务域").
|
||||
Description("空格选择 回车确认").
|
||||
Options(options...).
|
||||
Height(height).
|
||||
Value(&selected).
|
||||
Validate(func(values []string) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("至少选择一个授权业务域")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func authLoginHuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.FieldSeparator = lipgloss.NewStyle().SetString("\n")
|
||||
|
||||
t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue)
|
||||
t.Focused.Card = t.Focused.Base
|
||||
t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1)
|
||||
t.Focused.Description = authLoginMutedStyle()
|
||||
t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.MultiSelectSelector = t.Focused.SelectSelector
|
||||
t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue)
|
||||
t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted)
|
||||
t.Focused.FocusedButton = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}).
|
||||
Background(authLoginBrandBlue).
|
||||
Padding(0, 2).
|
||||
Bold(true)
|
||||
t.Focused.BlurredButton = lipgloss.NewStyle().
|
||||
Foreground(authLoginInk).
|
||||
Background(authLoginLine).
|
||||
Padding(0, 2)
|
||||
t.Focused.Next = t.Focused.FocusedButton
|
||||
t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.TextInput.Placeholder = authLoginMutedStyle()
|
||||
t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Blurred = t.Focused
|
||||
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine)
|
||||
t.Blurred.Card = t.Blurred.Base
|
||||
t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1)
|
||||
t.Blurred.Description = authLoginMutedStyle()
|
||||
t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector
|
||||
t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Blurred.NextIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.PrevIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Group.Title = t.Focused.Title
|
||||
t.Group.Description = t.Focused.Description
|
||||
|
||||
t.Help.ShortKey = authLoginMutedStyle()
|
||||
t.Help.ShortDesc = authLoginMutedStyle()
|
||||
t.Help.ShortSeparator = authLoginMutedStyle()
|
||||
t.Help.FullKey = authLoginMutedStyle()
|
||||
t.Help.FullDesc = authLoginMutedStyle()
|
||||
t.Help.FullSeparator = authLoginMutedStyle()
|
||||
t.Help.Ellipsis = authLoginMutedStyle()
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func authLoginStatusLine(message string) string {
|
||||
return fmt.Sprintf("%s %s",
|
||||
lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"),
|
||||
lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message),
|
||||
)
|
||||
}
|
||||
|
||||
func authLoginInfoLine(key, value string) string {
|
||||
label := authLoginMutedStyle().Width(14).Render(key + ":")
|
||||
return fmt.Sprintf("%s %s", label, value)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
if recommend || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool {
|
||||
return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool {
|
||||
if !hasAuthorizationFlow || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
return plan.AllGranted || len(plan.Scopes) == 0
|
||||
}
|
||||
|
||||
func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool {
|
||||
if cmd == nil || cmd.Root() == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return true
|
||||
}
|
||||
flags := cmd.Root().PersistentFlags()
|
||||
return !flags.Changed("format")
|
||||
}
|
||||
|
||||
func loginRecommendProductLabel(product pat.LoginRecommendProduct) string {
|
||||
name := strings.TrimSpace(product.ProductName)
|
||||
if name == "" || name == product.ProductCode {
|
||||
name = product.ProductCode
|
||||
}
|
||||
summary := strings.TrimSpace(product.Summary)
|
||||
if summary != "" {
|
||||
summary = " - " + clipRunes(summary, 42)
|
||||
}
|
||||
return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary)
|
||||
}
|
||||
|
||||
func clipRunes(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func clearCompatCache() {
|
||||
store := cacheStoreFromEnv()
|
||||
if store != nil {
|
||||
@@ -573,13 +1047,158 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
recommend, err := cmd.Flags().GetBool("recommend")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||
}
|
||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
}
|
||||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
return selector, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
|
||||
type contactProfileIdentity struct {
|
||||
CorpID string
|
||||
CorpName string
|
||||
UserID string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
if caller == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
restoreProfile := pushRuntimeProfile(corpID)
|
||||
defer restoreProfile()
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
|
||||
"profile": corpID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||||
}
|
||||
|
||||
updated := *data
|
||||
if identity.CorpName != "" {
|
||||
updated.CorpName = identity.CorpName
|
||||
}
|
||||
if identity.UserID != "" {
|
||||
updated.UserID = identity.UserID
|
||||
}
|
||||
if identity.UserName != "" {
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
return nil
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = updated
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||||
if result == nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
for _, block := range result.Content {
|
||||
if strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||||
var payload struct {
|
||||
Result []struct {
|
||||
OrgEmployeeModel struct {
|
||||
CorpID string `json:"corpId"`
|
||||
OrgName string `json:"orgName"`
|
||||
UserID string `json:"userId"`
|
||||
UserIDLower string `json:"userid"`
|
||||
OrgUserName string `json:"orgUserName"`
|
||||
Name string `json:"name"`
|
||||
} `json:"orgEmployeeModel"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
if len(payload.Result) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
org := payload.Result[0].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func authStatusAuthenticated(data *authpkg.TokenData) bool {
|
||||
if data == nil {
|
||||
return false
|
||||
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -26,6 +27,9 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
@@ -180,8 +184,710 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"corp_primary org", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
|
||||
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: "client-app",
|
||||
ClientSecret: authpkg.PlainSecret("secret-app"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
|
||||
t.Fatalf("profiles after logout = %#v, want empty", cfg)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("primary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("secondary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychain() {
|
||||
t.Fatal("legacy auth-token mirror should be deleted")
|
||||
}
|
||||
appConfig, err := authpkg.LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig() error = %v", err)
|
||||
}
|
||||
if appConfig == nil || appConfig.ClientID != "client-app" {
|
||||
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
|
||||
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("selected primary profile token should be deleted")
|
||||
}
|
||||
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("unselected secondary profile token should be retained")
|
||||
}
|
||||
loaded, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
|
||||
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
return root
|
||||
}
|
||||
|
||||
t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) {
|
||||
t.Fatal("--recommend must not show the post-login product TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive --recommend should still use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without recommend shows two-step authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("default interactive login should show post-login authorization TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive post-login authorization should use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit json keeps machine mode", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("explicit --format json must not show post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("explicit --format json must keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("table without recommend shows authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "table"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) {
|
||||
t.Fatal("table format should show post-login TUI without --recommend")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) {
|
||||
t.Fatal("table format should use human authorization flow in an interactive terminal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non interactive skips selector", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) {
|
||||
t.Fatal("non-interactive login should skip post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) {
|
||||
t.Fatal("non-interactive login should keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) {
|
||||
t.Fatal("login without a post-login authorization flow should not switch default json to human mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
|
||||
label := loginRecommendProductLabel(pat.LoginRecommendProduct{
|
||||
ProductCode: "approval",
|
||||
ProductName: "审批",
|
||||
Summary: "审批实例,审批模板,审批任务管理",
|
||||
ScopeCount: 12,
|
||||
})
|
||||
if label != "approval 审批 - 审批实例,审批模板,审批任务管理" {
|
||||
t.Fatalf("label = %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set yes: %v", err)
|
||||
}
|
||||
if err := login.Flags().Set("recommend", "true"); err != nil {
|
||||
t.Fatalf("set recommend: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.Recommend {
|
||||
t.Fatal("Recommend = false, want true")
|
||||
}
|
||||
if !cfg.Yes {
|
||||
t.Fatal("Yes = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
}
|
||||
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
|
||||
t.Fatal("Force=false should still force authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("--recommend must not call the post-login guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("--recommend must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("--recommend must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("--recommend must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token", "--recommend"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("--recommend plan recommend = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want all-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("already-granted recommended auth must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("already-granted recommended auth must not call product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want already-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
|
||||
var sawTokenBeforeScopeTUI bool
|
||||
var sawTokenBeforeProductTUI bool
|
||||
var sawTokenBeforePlan bool
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before scope TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeScopeTUI = true
|
||||
return pat.LoginRecommendScopeAll, nil
|
||||
}
|
||||
loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before product TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeProductTUI = true
|
||||
if len(products) != 1 || products[0].ProductCode != "calendar" {
|
||||
t.Fatalf("selector products = %+v, want calendar", products)
|
||||
}
|
||||
return []string{"calendar"}, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}, beforeCall: func(toolName string) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before %s error = %v", toolName, err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken)
|
||||
}
|
||||
sawTokenBeforePlan = true
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !sawTokenBeforeScopeTUI {
|
||||
t.Fatal("scope-mode TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforeProductTUI {
|
||||
t.Fatal("product-domain TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforePlan {
|
||||
t.Fatal("authorization plan was not called after token save")
|
||||
}
|
||||
if len(fake.tools) != 3 {
|
||||
t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("discovery plan recommend = %#v, want true", got)
|
||||
}
|
||||
if got := fake.args[1]["recommend"]; got != false {
|
||||
t.Fatalf("selected all-scope plan recommend = %#v, want false", got)
|
||||
}
|
||||
if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
|
||||
t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
token := &authpkg.TokenData{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "ding32fff839a3e0105d",
|
||||
ClientID: "client-id",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
}}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
}
|
||||
if token.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
|
||||
}
|
||||
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
|
||||
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
|
||||
}
|
||||
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
||||
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type authLoginRecommendSequenceCaller struct {
|
||||
responses []string
|
||||
tools []string
|
||||
args []map[string]any
|
||||
beforeCall func(toolName string)
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if f.beforeCall != nil {
|
||||
f.beforeCall(toolName)
|
||||
}
|
||||
f.tools = append(f.tools, toolName)
|
||||
copiedArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copiedArgs[key] = value
|
||||
}
|
||||
f.args = append(f.args, copiedArgs)
|
||||
response := `{"success":true,"data":{}}`
|
||||
if len(f.responses) > 0 {
|
||||
response = f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
if got == nil {
|
||||
return len(want) == 0
|
||||
}
|
||||
switch values := got.(type) {
|
||||
case []string:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
t.Cleanup(func() {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
})
|
||||
|
||||
for _, token := range tokens {
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
|
||||
}
|
||||
}
|
||||
return configDir
|
||||
}
|
||||
|
||||
func authLogoutTestToken(corpID string) *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access-" + corpID,
|
||||
RefreshToken: "refresh-" + corpID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpID + " org",
|
||||
UserID: "user-" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client-" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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,
|
||||
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)
|
||||
},
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -45,8 +46,17 @@ const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
devappProductID = "devapp"
|
||||
devappServerPath = "/server/op-app"
|
||||
)
|
||||
|
||||
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
|
||||
// from the configured gateway base URL, so it follows the active environment
|
||||
// (production by default, pre when ~/.dws/mcp_url points at the pre gateway).
|
||||
func devappMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + devappServerPath
|
||||
}
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
@@ -220,12 +230,9 @@ func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
@@ -241,24 +248,27 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
dynamicMu.RLock()
|
||||
de := dynamicEndpoints
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
// Hardcoded built-in: devapp is pinned to the open-platform app-management
|
||||
// MCP server in source (NOT service discovery), per product decision.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == devappProductID {
|
||||
return devappMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
dynamicMu.RLock()
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if de != nil {
|
||||
if endpoint, ok := de[candidate]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := dynamicEndpoints[candidate]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +276,13 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
if tool := strings.TrimSpace(toolName); tool != "" {
|
||||
if endpoint, ok := dynamicToolEndpoints[tool]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
@@ -279,18 +291,69 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: edition-owned static/supplement endpoints. Helper-only
|
||||
// products such as devapp intentionally do not depend on Market discovery,
|
||||
// so the internal edition may provide only an endpoint and no tool list.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if endpoint, ok := editionServerEndpoint(candidate); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns the set of product IDs that have direct
|
||||
// runtime endpoints configured, sourced from dynamic server discovery.
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
return "", false
|
||||
}
|
||||
hooks := edition.Get()
|
||||
if hooks == nil {
|
||||
return "", false
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.SupplementServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo) (string, bool) {
|
||||
if fn == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(server.ID) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
for _, prefix := range server.Prefixes {
|
||||
if strings.TrimSpace(prefix) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
// through DINGTALK_<PRODUCT>_MCP_URL instead of requiring discovery.
|
||||
func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
ids := make(map[string]bool, len(dynamicProducts)+2)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[devappProductID] = true
|
||||
for key := range dynamicProducts {
|
||||
ids[key] = true
|
||||
}
|
||||
return ids
|
||||
@@ -365,15 +428,14 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
dynamicMu.RLock()
|
||||
da := dynamicAliases
|
||||
dynamicMu.RUnlock()
|
||||
trimmed := strings.TrimSpace(productID)
|
||||
if da != nil {
|
||||
if normalizedID, ok := da[trimmed]; ok && normalizedID != "" {
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
if normalizedID, ok := dynamicAliases[trimmed]; ok && normalizedID != "" {
|
||||
dynamicMu.RUnlock()
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
if normalizedID, ok := legacyDirectRuntimeAliases[trimmed]; ok {
|
||||
return normalizedID
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"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) {
|
||||
@@ -38,6 +39,107 @@ func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -112,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
return output.WriteJSON(w, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
|
||||
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
|
||||
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
||||
}
|
||||
@@ -123,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查登录状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查登录状态... "))
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
@@ -186,7 +188,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查网络连通性... ")
|
||||
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
|
||||
}
|
||||
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
@@ -228,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查缓存状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
@@ -300,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查版本更新... ")
|
||||
fmt.Fprint(w, tui.Dim("检查版本更新... "))
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
@@ -348,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
||||
|
||||
func printCheckResult(w io.Writer, r checkResult) {
|
||||
icon := statusIcon(r.Status)
|
||||
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
|
||||
message := r.Message
|
||||
switch r.Status {
|
||||
case statusPass:
|
||||
message = tui.Success(message)
|
||||
case statusWarn:
|
||||
message = tui.Warning(message)
|
||||
case statusFail:
|
||||
message = tui.Danger(message)
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s\n", icon, message)
|
||||
if r.Hint != "" {
|
||||
fmt.Fprintf(w, " %s\n", r.Hint)
|
||||
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) {
|
||||
|
||||
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查性能报告... ")
|
||||
fmt.Fprint(w, tui.Dim("检查性能报告... "))
|
||||
}
|
||||
|
||||
report, err := LoadLatestReport()
|
||||
|
||||
@@ -29,6 +29,7 @@ type GlobalFlags struct {
|
||||
JQ string
|
||||
Mock bool
|
||||
Output string
|
||||
Profile string
|
||||
Timeout int
|
||||
Token string
|
||||
Verbose bool
|
||||
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
_ = cmd.PersistentFlags().MarkHidden("output")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
|
||||
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
|
||||
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
|
||||
_ = cmd.PersistentFlags().MarkHidden("token")
|
||||
|
||||
@@ -55,9 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
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",
|
||||
"minutes list all",
|
||||
} {
|
||||
if cmd := lookupCommand(root, path); cmd != nil {
|
||||
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
|
||||
@@ -161,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
|
||||
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) {
|
||||
@@ -215,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -222,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
|
||||
// server's tools/list LIVE (by source) and projects each tool into a
|
||||
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
|
||||
// is injected into the schema command so the cli package can render
|
||||
// `dws schema dev.*` from real server schema without importing app/transport.
|
||||
//
|
||||
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
|
||||
// backs `dws dev doc search` (endpoint resolved dynamically, see
|
||||
// helperSourceEndpoint). Results are memoized per source per process so
|
||||
// repeated `dws schema dev.*` hit the network at most once per source. A failed
|
||||
// fetch is not cached, allowing a later retry within the same process.
|
||||
func newHelperToolFetcher() cli.HelperToolFetcher {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cached = map[string]map[string]cli.HelperToolSchema{}
|
||||
)
|
||||
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
|
||||
mu.Lock()
|
||||
if got, ok := cached[source]; ok {
|
||||
mu.Unlock()
|
||||
return got, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
endpoint, err := helperSourceEndpoint(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
cached[source] = schemas
|
||||
mu.Unlock()
|
||||
return schemas, nil
|
||||
}
|
||||
}
|
||||
|
||||
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
|
||||
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
|
||||
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
|
||||
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
|
||||
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
|
||||
func helperSourceEndpoint(source string) (string, error) {
|
||||
switch source {
|
||||
case "", "op-app", "devapp":
|
||||
return devappMCPEndpoint(), nil
|
||||
default:
|
||||
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
|
||||
// converts the descriptors. Auth and identity headers are resolved the same way
|
||||
// the runner does for direct-runtime invocations.
|
||||
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
|
||||
token := resolveRuntimeAuthToken(ctx, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
client := transport.NewClient(nil).WithAuth(token, headers)
|
||||
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
|
||||
for _, td := range result.Tools {
|
||||
out[td.Name] = cli.HelperToolSchema{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Properties: inputSchemaProperties(td.InputSchema),
|
||||
Required: inputSchemaRequired(td.InputSchema),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inputSchemaProperties pulls the "properties" object out of a deserialized
|
||||
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
|
||||
func inputSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// inputSchemaRequired pulls the "required" string list out of a deserialized
|
||||
// MCP inputSchema map.
|
||||
func inputSchemaRequired(schema map[string]any) []string {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+54
-3
@@ -310,7 +310,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
|
||||
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)
|
||||
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 {
|
||||
@@ -568,6 +612,13 @@ func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.
|
||||
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.
|
||||
@@ -580,7 +631,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_a"),
|
||||
authLogoutTestToken("corp_b"),
|
||||
)
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_b")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
Params: map[string]any{"limit": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
|
||||
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
|
||||
}
|
||||
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if content["multiProfile"] != true {
|
||||
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
|
||||
}
|
||||
if content["success"] != true {
|
||||
t.Fatalf("success = %#v, want true", content["success"])
|
||||
}
|
||||
profiles := content["profiles"].([]any)
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(profiles))
|
||||
}
|
||||
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
|
||||
entry := profiles[i].(map[string]any)
|
||||
if entry["corpId"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
|
||||
}
|
||||
resultPayload := entry["result"].(map[string]any)
|
||||
if resultPayload["runtimeProfile"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi {
|
||||
t.Fatal("multi = false, want true")
|
||||
}
|
||||
if len(selections) != 2 {
|
||||
t.Fatalf("selections len = %d, want 2", len(selections))
|
||||
}
|
||||
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
|
||||
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
|
||||
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a" {
|
||||
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == "corp_comma" {
|
||||
cfg.Profiles[i].Name = "alpha,beta"
|
||||
}
|
||||
}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if multi {
|
||||
t.Fatalf("multi = true, want false; selections=%#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
|
||||
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
|
||||
if err == nil {
|
||||
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("error = %q, want empty profile selector", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type multiProfileFallbackRunner struct{}
|
||||
|
||||
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"runtimeProfile": authpkg.RuntimeProfile(),
|
||||
"tool": invocation.Tool,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMultiSkillSrc creates a fake multi skill source tree with the given
|
||||
// subdir names, each containing a minimal SKILL.md.
|
||||
func writeMultiSkillSrc(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
src := t.TempDir()
|
||||
for _, n := range names {
|
||||
dir := filepath.Join(src, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
+200
-71
@@ -33,8 +33,9 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
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/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,6 +52,10 @@ const (
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
type patSuppressBrowserOpenKeyType struct{}
|
||||
|
||||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
@@ -153,36 +158,31 @@ func extractPatScopeError(err error) *PatScopeError {
|
||||
|
||||
// PrintPatAuthError prints a human-readable PAT authorization error.
|
||||
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "{\n")
|
||||
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
func currentPATOpenBrowser(ctx context.Context, configDir string) bool {
|
||||
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
|
||||
return false
|
||||
}
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
@@ -225,8 +228,11 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
@@ -237,15 +243,19 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func patAuthorizationURIFromData(data map[string]any) string {
|
||||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||||
value, _ := data[key].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
timeout := PatAuthRetryTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
@@ -253,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
start := time.Now()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
@@ -280,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
@@ -288,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
tui.Dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
@@ -340,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patMaxPollInterval caps a server-provided poll interval so a malformed
|
||||
// response cannot make the CLI look permanently stuck.
|
||||
patMaxPollInterval = 30 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
@@ -350,6 +362,12 @@ type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
|
||||
|
||||
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return f(ctx, invocation)
|
||||
}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
@@ -364,8 +382,8 @@ func openPATAuthorizationURI(rawURI string) error {
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper while still
|
||||
// preserving the original data.uri in structured output.
|
||||
// That variant is normalized by the PAT error contract helper before being
|
||||
// printed, opened, or returned in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
@@ -382,6 +400,70 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheck(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
) error {
|
||||
if retry == nil {
|
||||
return patErr
|
||||
}
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWaitOnly(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
output io.Writer,
|
||||
) error {
|
||||
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWithMode(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
retryAfterApproval bool,
|
||||
) error {
|
||||
if retryAfterApproval && retry == nil {
|
||||
return patErr
|
||||
}
|
||||
runner := &runtimeRunner{
|
||||
globalFlags: globalFlags,
|
||||
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if retry != nil {
|
||||
if err := retry(retryCtx); err != nil {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
Kind: "direct_pat_authorization",
|
||||
Stage: "auth_login_recommend",
|
||||
CanonicalProduct: defaultPATProductID,
|
||||
Tool: "pat.batch_grant",
|
||||
CanonicalPath: "pat.batch_grant",
|
||||
Params: map[string]any{
|
||||
"retryAfterApproval": retryAfterApproval,
|
||||
},
|
||||
}, patErr, defaultConfigDir(), output)
|
||||
return err
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
@@ -399,16 +481,25 @@ func handlePatAuthCheck(
|
||||
var patData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
AuthURL string `json:"authUrl"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
PollIntervalSecs int `json:"pollIntervalSeconds"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthURL
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthorizationURL
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
@@ -416,7 +507,7 @@ func handlePatAuthCheck(
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
openBrowser := currentPATOpenBrowser(ctx, configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
@@ -466,44 +557,40 @@ func handlePatAuthCheck(
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
greenFn := color.New(color.FgGreen).SprintFunc()
|
||||
yellowFn := color.New(color.FgYellow).SprintFunc()
|
||||
redFn := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
|
||||
if patData.Data.Desc != "" {
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
|
||||
fmt.Fprintln(output)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
||||
pollCtx, patData.Data.FlowID, configDir, output,
|
||||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
@@ -519,11 +606,11 @@ func handlePatAuthCheck(
|
||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
||||
if exchErr != nil {
|
||||
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
@@ -533,13 +620,23 @@ func handlePatAuthCheck(
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
if shouldSkipPATRetryAfterApproval(invocation) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Workaround: brief delay to let server-side authorization state propagate
|
||||
// before retrying. Without this the retry may use stale credentials.
|
||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
@@ -548,7 +645,7 @@ func handlePatAuthCheck(
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
@@ -556,7 +653,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
@@ -564,7 +661,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
@@ -572,11 +669,23 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
|
||||
if invocation.Params == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := invocation.Params["retryAfterApproval"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
retry, ok := value.(bool)
|
||||
return ok && !retry
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
@@ -630,7 +739,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
b, err := jsonutil.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
@@ -655,6 +764,13 @@ func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
|
||||
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
|
||||
}
|
||||
|
||||
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
|
||||
if interval <= 0 {
|
||||
interval = patPollInterval
|
||||
}
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
@@ -671,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
@@ -686,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
@@ -738,6 +853,20 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePATPollInterval(seconds int) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return patPollInterval
|
||||
}
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if interval > patMaxPollInterval {
|
||||
return patMaxPollInterval
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
|
||||
@@ -608,8 +608,14 @@ func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *
|
||||
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +662,192 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
retried.Store(true)
|
||||
retryHadKey.Store(IsPatRetrying(ctx))
|
||||
return nil
|
||||
}, &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheck error = %v", err)
|
||||
}
|
||||
if !retried.Load() {
|
||||
t.Fatal("expected direct PAT auth retry callback to run")
|
||||
}
|
||||
if !retryHadKey.Load() {
|
||||
t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "授权完成,正在重试") {
|
||||
t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权成功") {
|
||||
t.Fatalf("wait-only auth should still report success, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened bool
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("wait-only auth must not open a second browser tab")
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权链接:") {
|
||||
t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePATPollInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seconds int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "default", seconds: 0, want: patPollInterval},
|
||||
{name: "server value", seconds: 3, want: 3 * time.Second},
|
||||
{name: "cap excessive value", seconds: 90, want: patMaxPollInterval},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolvePATPollInterval(tt.seconds); got != tt.want {
|
||||
t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" {
|
||||
t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != wantURL {
|
||||
t.Fatalf("data.uri = %q, want %q", got, wantURL)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
@@ -951,6 +1143,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") {
|
||||
t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
@@ -959,8 +1157,11 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from json PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from json PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
|
||||
@@ -995,7 +1196,7 @@ 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","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -1015,6 +1216,12 @@ 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") {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
|
||||
@@ -1246,8 +1453,8 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) {
|
||||
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,8 +1499,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test
|
||||
if opened != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
|
||||
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "root profile before command",
|
||||
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "profile after leaf command",
|
||||
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
|
||||
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
|
||||
},
|
||||
{
|
||||
name: "equals form",
|
||||
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "three profiles",
|
||||
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "already quoted by shell remains unchanged",
|
||||
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "single profile remains unchanged",
|
||||
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "trailing comma before next flag remains validation input",
|
||||
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, _ := normalizeProfileFlagArgs(tc.args)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
|
||||
if got != "corpA,corpB" {
|
||||
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
|
||||
restore := normalizeProcessProfileArgs()
|
||||
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
restore()
|
||||
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newProfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "组织 profile 管理",
|
||||
Long: `管理本机已登录的钉钉组织 profile。
|
||||
|
||||
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
|
||||
profile switch/use 才会持久修改默认组织上下文。`,
|
||||
Example: ` dws profile list
|
||||
dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出已登录组织 profile",
|
||||
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
|
||||
Example: ` dws profile list
|
||||
dws profile list --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
|
||||
}
|
||||
writeProfileListTable(cmd.OutOrStdout(), cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newProfileUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [name|corpId|-]",
|
||||
Short: "切换当前组织 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
|
||||
Example: ` dws profile use <corpId>
|
||||
dws profile use --name "钉钉"
|
||||
dws profile use -`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileSwitchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "switch [name|corpId|-]",
|
||||
Short: "切换当前组织 profile",
|
||||
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
|
||||
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
|
||||
需要只影响单次业务命令时,请使用全局 --profile。`,
|
||||
Example: ` dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch --corpId <corpId>
|
||||
dws profile switch --name "钉钉"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
|
||||
_ = cmd.Flags().MarkHidden("corp-id")
|
||||
_ = cmd.Flags().MarkHidden("corpid")
|
||||
_ = cmd.Flags().MarkHidden("corp")
|
||||
}
|
||||
|
||||
var (
|
||||
profileSwitchSelector = selectProfileSwitchProfile
|
||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
const (
|
||||
profileSwitchVisibleOptions = 5
|
||||
profileSwitchCellPadding = 1
|
||||
profileSwitchOrgWidth = 34
|
||||
profileSwitchStatusWidth = 10
|
||||
)
|
||||
|
||||
var profileSwitchRenderer = newProfileSwitchRenderer()
|
||||
|
||||
func newProfileSwitchRenderer() *lipgloss.Renderer {
|
||||
renderer := lipgloss.NewRenderer(io.Discard)
|
||||
renderer.SetColorProfile(termenv.TrueColor)
|
||||
renderer.SetHasDarkBackground(true)
|
||||
return renderer
|
||||
}
|
||||
|
||||
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
selector, err := profileSwitchSelectorFromCommand(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI := false
|
||||
if selector == "" {
|
||||
selector, err = profileSwitchSelector(cmd, configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI = true
|
||||
}
|
||||
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
|
||||
}
|
||||
|
||||
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
|
||||
selectors := make([]string, 0, 2)
|
||||
if len(args) > 0 {
|
||||
selectors = append(selectors, strings.TrimSpace(args[0]))
|
||||
}
|
||||
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
|
||||
value, changed := changedStringFlag(cmd, name)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
|
||||
}
|
||||
selectors = append(selectors, value)
|
||||
}
|
||||
if len(selectors) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
selector := selectors[0]
|
||||
for _, candidate := range selectors[1:] {
|
||||
if candidate != selector {
|
||||
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
|
||||
}
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
|
||||
if cmd == nil || cmd.Flags() == nil {
|
||||
return "", false
|
||||
}
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Changed {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(flag.Value.String()), true
|
||||
}
|
||||
|
||||
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
|
||||
var (
|
||||
profile *authpkg.Profile
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(selector) == "-" {
|
||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
||||
} else {
|
||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||
}
|
||||
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
|
||||
if !profileSwitchInteractiveTerminal() {
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||
}
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = strings.TrimSpace(cfg.PrimaryProfile)
|
||||
}
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
}
|
||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
||||
}
|
||||
|
||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.ErrOrStderr()),
|
||||
tea.WithContext(cmd.Context()),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
|
||||
}
|
||||
final, ok := finalModel.(profileSwitchTUIModel)
|
||||
if !ok || final.aborted || !final.submitted {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return final.selectedCorpID(), nil
|
||||
}
|
||||
|
||||
type profileSwitchTUIModel struct {
|
||||
cfg *authpkg.ProfilesConfig
|
||||
profiles []authpkg.Profile
|
||||
selected int
|
||||
offset int
|
||||
submitted bool
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
|
||||
model := profileSwitchTUIModel{cfg: cfg}
|
||||
if cfg != nil {
|
||||
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
}
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
model.ensureSelectedVisible()
|
||||
return model
|
||||
}
|
||||
|
||||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||||
sorted := append([]authpkg.Profile(nil), profiles...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left, leftOK := profileSwitchSortTime(sorted[i])
|
||||
right, rightOK := profileSwitchSortTime(sorted[j])
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return false
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
|
||||
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
|
||||
if t, ok := parseProfileSwitchTime(raw); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseProfileSwitchTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.profiles)-1 {
|
||||
m.selected++
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "enter":
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) View() string {
|
||||
var b strings.Builder
|
||||
title := profileSwitchTitleStyle().Render("选择要切换的组织")
|
||||
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(hint)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.tableView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) tableView() string {
|
||||
rows := []string{
|
||||
profileSwitchBorder("┌", "┬", "┐"),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchBorder("├", "┼", "┤"),
|
||||
}
|
||||
for i := 0; i < profileSwitchVisibleOptions; i++ {
|
||||
idx := m.offset + i
|
||||
if idx >= 0 && idx < len(m.profiles) {
|
||||
rows = append(rows, m.profileRow(idx))
|
||||
continue
|
||||
}
|
||||
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
|
||||
}
|
||||
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) profileRow(idx int) string {
|
||||
profile := m.profiles[idx]
|
||||
org, status := profileSwitchProfileCells(profile, m.cfg)
|
||||
style := profileSwitchNormalRowStyle()
|
||||
if idx == m.selected {
|
||||
org = "› " + org
|
||||
style = profileSwitchSelectedRowStyle()
|
||||
} else {
|
||||
org = " " + org
|
||||
}
|
||||
return profileSwitchStyledTableLine(org, status, style)
|
||||
}
|
||||
|
||||
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
|
||||
if len(m.profiles) == 0 {
|
||||
m.selected = 0
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
if m.selected < 0 {
|
||||
m.selected = 0
|
||||
}
|
||||
if m.selected >= len(m.profiles) {
|
||||
m.selected = len(m.profiles) - 1
|
||||
}
|
||||
if m.selected < m.offset {
|
||||
m.offset = m.selected
|
||||
}
|
||||
if m.selected >= m.offset+profileSwitchVisibleOptions {
|
||||
m.offset = m.selected - profileSwitchVisibleOptions + 1
|
||||
}
|
||||
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
if m.offset < 0 {
|
||||
m.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.profiles[m.selected].CorpID)
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
org, status := profileSwitchProfileCells(p, cfg)
|
||||
if status == "" {
|
||||
return org
|
||||
}
|
||||
return strings.Join([]string{org, status}, " | ")
|
||||
}
|
||||
|
||||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||||
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && p.CorpID == cfg.CurrentProfile {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileSwitchBorder(left, sep, right string) string {
|
||||
segments := []string{
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
|
||||
}
|
||||
|
||||
func profileSwitchTableLine(org, status string) string {
|
||||
cells := []string{
|
||||
profileSwitchTableCell(org, profileSwitchOrgWidth),
|
||||
profileSwitchTableCell(status, profileSwitchStatusWidth),
|
||||
}
|
||||
return "│" + strings.Join(cells, "│") + "│"
|
||||
}
|
||||
|
||||
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
|
||||
cells := []string{
|
||||
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
|
||||
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
|
||||
}
|
||||
|
||||
func profileSwitchTableSeparator() string {
|
||||
return profileSwitchBorderStyle().Render("│")
|
||||
}
|
||||
|
||||
func profileSwitchTableCell(value string, width int) string {
|
||||
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
|
||||
padding := strings.Repeat(" ", profileSwitchCellPadding)
|
||||
return padding + padProfileDisplayCell(clipped, width) + padding
|
||||
}
|
||||
|
||||
func padProfileDisplayCell(value string, width int) string {
|
||||
padding := width - lipgloss.Width(value)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
return value + strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
func profileSwitchCellWidth(contentWidth int) int {
|
||||
return contentWidth + profileSwitchCellPadding*2
|
||||
}
|
||||
|
||||
func profileSwitchSelectedRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchNormalRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
|
||||
}
|
||||
|
||||
func profileSwitchHeaderStyle() lipgloss.Style {
|
||||
return profileSwitchMutedStyle().Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchBorderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
|
||||
}
|
||||
|
||||
func profileSwitchTitleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
|
||||
}
|
||||
|
||||
type profileListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []profileView `json:"profiles"`
|
||||
}
|
||||
|
||||
type profileUseResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Profile profileView `json:"profile"`
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileListResponse{
|
||||
Success: true,
|
||||
PrimaryProfile: cfg.PrimaryProfile,
|
||||
CurrentProfile: cfg.CurrentProfile,
|
||||
PreviousProfile: cfg.PreviousProfile,
|
||||
Profiles: profileViews(cfg),
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileUseResponse{Success: true}
|
||||
if profile != nil {
|
||||
primaryProfile := ""
|
||||
currentProfile := ""
|
||||
if cfg != nil {
|
||||
primaryProfile = cfg.PrimaryProfile
|
||||
currentProfile = cfg.CurrentProfile
|
||||
}
|
||||
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
fmt.Fprintln(w, "未找到已登录 profile")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
current := ""
|
||||
if p.CorpID == cfg.CurrentProfile {
|
||||
current = "*"
|
||||
}
|
||||
primary := ""
|
||||
if p.CorpID == cfg.PrimaryProfile {
|
||||
primary = "*"
|
||||
}
|
||||
user := p.UserName
|
||||
if user == "" {
|
||||
user = p.UserID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%-3s %-3s %-28s %-34s %-10s %s\n",
|
||||
current,
|
||||
primary,
|
||||
clipProfileCell(profileOrgName(p), 28),
|
||||
clipProfileCell(p.CorpID, 34),
|
||||
status,
|
||||
user,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func profileUseMessage(profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return "[OK] 当前 profile 已切换"
|
||||
}
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
orgName := strings.TrimSpace(profile.CorpName)
|
||||
if orgName == "" {
|
||||
orgName = profileOrgName(*profile)
|
||||
}
|
||||
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
|
||||
}
|
||||
|
||||
func profileOrgName(p authpkg.Profile) string {
|
||||
if v := strings.TrimSpace(p.CorpName); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
|
||||
return profileView{
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
UserName: p.UserName,
|
||||
ClientID: p.ClientID,
|
||||
Status: p.Status,
|
||||
AuthorizedDomains: p.AuthorizedDomains,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: p.CorpID == primaryProfile,
|
||||
IsCurrent: p.CorpID == currentProfile,
|
||||
}
|
||||
}
|
||||
|
||||
func clipProfileCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return string(runes[:limit-3]) + "..."
|
||||
}
|
||||
|
||||
func clipProfileDisplayCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(value) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if lipgloss.Width(b.String())+rw > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
target := limit - 3
|
||||
var b strings.Builder
|
||||
width := 0
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if width+rw > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
width += rw
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
|
||||
profile := &authpkg.Profile{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
|
||||
t.Fatalf("writeProfileUseJSON() error = %v", err)
|
||||
}
|
||||
var resp profileUseResponse
|
||||
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
|
||||
}
|
||||
if resp.Profile.CorpName != "B Org" {
|
||||
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
|
||||
}
|
||||
if !resp.Profile.IsCurrent {
|
||||
t.Fatalf("isCurrent = false, want true")
|
||||
}
|
||||
if resp.Profile.IsPrimary {
|
||||
t.Fatalf("isPrimary = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false, want true")
|
||||
}
|
||||
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
|
||||
}
|
||||
for _, p := range resp.Profiles {
|
||||
if p.CorpName == "" {
|
||||
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||||
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_secondary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
|
||||
t.Fatalf("error = %v, want conflicting selector validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
called := false
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
called = true
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("profile switch without args did not invoke TUI selector")
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_primary",
|
||||
CurrentProfile: "corp_secondary",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
CorpID: "corp_primary",
|
||||
CorpName: "第一组织",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
CorpID: "corp_secondary",
|
||||
CorpName: "第二组织",
|
||||
UserName: "bob",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
|
||||
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
|
||||
for _, label := range []string{primary, current} {
|
||||
if strings.Contains(label, "\n") {
|
||||
t.Fatalf("profile switch label contains newline: %q", label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(primary, "第一组织") {
|
||||
t.Fatalf("primary option missing organization name: %q", primary)
|
||||
}
|
||||
if !strings.Contains(current, "当前组织") {
|
||||
t.Fatalf("current option missing current marker: %q", current)
|
||||
}
|
||||
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
|
||||
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
|
||||
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(2)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
view := model.tableView()
|
||||
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
|
||||
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
|
||||
}
|
||||
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
|
||||
if strings.Contains(view, unwanted) {
|
||||
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
|
||||
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
if got := strings.Count(view, profile.CorpID); got != 0 {
|
||||
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "old",
|
||||
CurrentProfile: "old",
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
|
||||
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
|
||||
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
|
||||
},
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, "old")
|
||||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||||
wantOrder := []string{"new", "fallback", "old"}
|
||||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != "old" {
|
||||
t.Fatalf("selectedCorpID = %q, want old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(7)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
for step := 0; step < 6; step++ {
|
||||
view := model.tableView()
|
||||
if got := strings.Count(view, "›"); got != 1 {
|
||||
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
name := profileOrgName(profile)
|
||||
if got := strings.Count(view, name); got > 1 {
|
||||
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
|
||||
}
|
||||
}
|
||||
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = next.(profileSwitchTUIModel)
|
||||
}
|
||||
if model.selected != 6 || model.offset != 2 {
|
||||
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
|
||||
rows := []string{
|
||||
profileSwitchTableLine("组织名", "本地状态"),
|
||||
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
|
||||
profileSwitchTableLine(" ACME", ""),
|
||||
profileSwitchTableLine("", ""),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
|
||||
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
|
||||
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
|
||||
}
|
||||
wantWidth := lipgloss.Width(rows[0])
|
||||
for i, row := range rows {
|
||||
if got := lipgloss.Width(row); got != wantWidth {
|
||||
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
|
||||
}
|
||||
if got := strings.Count(row, "│"); got != 3 {
|
||||
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
|
||||
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: corpID,
|
||||
CurrentProfile: corpID,
|
||||
}
|
||||
label := profileSwitchOptionLabel(authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: "钉钉",
|
||||
}, cfg)
|
||||
for _, want := range []string{"钉钉", "当前组织"} {
|
||||
if !strings.Contains(label, want) {
|
||||
t.Fatalf("profile switch label missing %q in %q", want, label)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
|
||||
if strings.Contains(label, unwanted) {
|
||||
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_00",
|
||||
CurrentProfile: "corp_00",
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
corpID := fmt.Sprintf("corp_%02d", i)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: fmt.Sprintf("组织%02d", i),
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "switch"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "use"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
profileSwitchInteractiveTerminal = oldInteractive
|
||||
})
|
||||
profileSwitchInteractiveTerminal = func() bool { return false }
|
||||
|
||||
_, err := selectProfileSwitchProfile(nil, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
|
||||
t.Fatalf("error = %v, want profile selector hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "corp_a",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B 组织",
|
||||
UserID: "bob-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeProfileListTable(&buf, cfg)
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"ORG_NAME",
|
||||
"钉钉(中国)信息技术有限公司",
|
||||
"B 组织",
|
||||
"corp_a",
|
||||
"corp_b",
|
||||
} {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(want)) {
|
||||
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
|
||||
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
|
||||
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
|
||||
got := profileUseMessage(&authpkg.Profile{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "ding8196",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
})
|
||||
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
|
||||
if !bytes.Contains([]byte(got), []byte(want)) {
|
||||
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
|
||||
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
+130
-9
@@ -67,6 +67,9 @@ func Execute() (exitCode int) {
|
||||
}
|
||||
}()
|
||||
|
||||
restoreArgs := normalizeProcessProfileArgs()
|
||||
defer restoreArgs()
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
@@ -96,6 +99,7 @@ func Execute() (exitCode int) {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -114,6 +118,36 @@ func isUnknownCommandError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||||
}
|
||||
|
||||
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
|
||||
// (`required flag(s) "email" not set`) into the wukong-aligned form
|
||||
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
|
||||
// this error directly (it does not pass through FlagErrorFunc), so it is
|
||||
// normalised here. The substring "required flag" is preserved for compatibility
|
||||
// with existing assertions; flag names gain the "--" prefix and quotes are
|
||||
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
|
||||
func rewordRequiredFlagError(err error) error {
|
||||
if err == nil {
|
||||
return err
|
||||
}
|
||||
const pfx = "required flag(s) "
|
||||
const sfx = " not set"
|
||||
msg := err.Error()
|
||||
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
|
||||
return err
|
||||
}
|
||||
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
|
||||
var flags []string
|
||||
for _, part := range strings.Split(mid, ", ") {
|
||||
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
|
||||
flags = append(flags, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return err
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
|
||||
}
|
||||
|
||||
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
|
||||
//
|
||||
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
|
||||
@@ -267,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
flags := &GlobalFlags{}
|
||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
CatalogBaseURLOverride: DiscoveryBaseURL(),
|
||||
@@ -290,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
if flags.ClientID != "" {
|
||||
authpkg.SetClientID(flags.ClientID)
|
||||
@@ -323,12 +359,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
genSkillsCmd.Hidden = true
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(),
|
||||
newAuthCommand(patCaller),
|
||||
newProfileCommand(),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newCompletionCommand(root),
|
||||
@@ -354,7 +393,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
@@ -372,8 +410,87 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAuthCommand() *cobra.Command {
|
||||
return buildAuthCommand()
|
||||
func preparseProfileFlag(args []string) string {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
original := append([]string(nil), os.Args...)
|
||||
if len(os.Args) > 1 {
|
||||
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
|
||||
os.Args = append([]string{os.Args[0]}, normalized...)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
os.Args = original
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
|
||||
if len(args) == 0 {
|
||||
return args, false
|
||||
}
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
switch {
|
||||
case trimmed == "--profile":
|
||||
out = append(out, arg)
|
||||
if i+1 >= len(args) {
|
||||
continue
|
||||
}
|
||||
value, next := collectProfileFlagValue(args[i+1], args, i+2)
|
||||
out = append(out, value)
|
||||
i = next - 1
|
||||
case strings.HasPrefix(trimmed, "--profile="):
|
||||
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
|
||||
out = append(out, "--profile="+value)
|
||||
i = next - 1
|
||||
default:
|
||||
out = append(out, arg)
|
||||
}
|
||||
}
|
||||
return out, argsChanged(args, out)
|
||||
}
|
||||
|
||||
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
|
||||
parts := []string{strings.TrimSpace(first)}
|
||||
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
|
||||
candidate := strings.TrimSpace(args[next])
|
||||
if candidate == "" || strings.HasPrefix(candidate, "-") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, candidate)
|
||||
next++
|
||||
}
|
||||
return strings.Join(parts, ""), next
|
||||
}
|
||||
|
||||
func argsChanged(before, after []string) bool {
|
||||
if len(before) != len(after) {
|
||||
return true
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return buildAuthCommand(patCaller)
|
||||
}
|
||||
|
||||
func newSkillCommand() *cobra.Command {
|
||||
@@ -474,7 +591,7 @@ func newCacheCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
|
||||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
selected := selectServersForProduct(servers, product)
|
||||
@@ -596,7 +713,7 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader)
|
||||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||||
}
|
||||
|
||||
func newGenerateSkillsCommand() *cobra.Command {
|
||||
@@ -770,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
"profile": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"recovery": true,
|
||||
@@ -796,7 +914,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"plugin": true, "skill": true, "cache": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
@@ -1471,8 +1589,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
|
||||
}
|
||||
|
||||
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
|
||||
@@ -1659,8 +1779,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
|
||||
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)
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
|
||||
|
||||
slog.Debug("plugin: stdio server registered",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
case "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
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:
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
|
||||
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())
|
||||
}
|
||||
|
||||
+75
-10
@@ -6,8 +6,10 @@ import (
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
@@ -50,38 +52,43 @@ func renderRootHelp(root *cobra.Command) {
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
w := root.OutOrStdout()
|
||||
|
||||
_, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console"))
|
||||
_, _ = fmt.Fprintln(w, tui.Rule(76))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
if len(services) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "No MCP services discovered.")
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered."))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(w, "Discovered MCP Services:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, service := range services {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, "Usage:")
|
||||
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Usage:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <service> [command] [flags]"))
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <command> [flags]"))
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "Utility Commands:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Utility Commands:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, utility := range utilities {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
renderRootGlobalFlags(root)
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
// hint (or any other root-level guidance) after browsing all available
|
||||
@@ -90,10 +97,68 @@ func renderRootHelp(root *cobra.Command) {
|
||||
// it and dropped this, so we restore it explicitly here.
|
||||
if long := strings.TrimSpace(root.Long); long != "" {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, long)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
flags := visiblePersistentFlags(root)
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
w := root.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, flag := range flags {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
flags := make([]*pflag.Flag, 0)
|
||||
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Hidden {
|
||||
return
|
||||
}
|
||||
flags = append(flags, flag)
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func formatRootFlag(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
name := "--" + flag.Name
|
||||
if flag.Value != nil && flag.Value.Type() != "bool" {
|
||||
name += " " + flag.Value.Type()
|
||||
}
|
||||
if flag.Shorthand == "" {
|
||||
return " " + name
|
||||
}
|
||||
return "-" + flag.Shorthand + ", " + name
|
||||
}
|
||||
|
||||
func commandShort(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
short := strings.TrimSpace(cmd.Short)
|
||||
if cmd.Name() == "help" && short == "Help about any command" {
|
||||
return i18n.T("查看任意命令的帮助信息")
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
|
||||
+256
-32
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -161,6 +162,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if multi {
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -178,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
if prefetchToken {
|
||||
go getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
@@ -238,6 +253,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
Selector string
|
||||
Profile authpkg.Profile
|
||||
}
|
||||
|
||||
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
|
||||
rawSelector = strings.TrimSpace(rawSelector)
|
||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(rawSelector, ",")
|
||||
selections := make([]multiProfileSelection, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
selector := strings.TrimSpace(part)
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||
}
|
||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, false, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
if seen[profile.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[profile.CorpID] = true
|
||||
selections = append(selections, multiProfileSelection{
|
||||
Selector: selector,
|
||||
Profile: *profile,
|
||||
})
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return selections, true, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
defer authpkg.SetRuntimeProfile(previousProfile)
|
||||
|
||||
entries := make([]any, 0, len(selections))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
entry := map[string]any{
|
||||
"selector": selection.Selector,
|
||||
"corpId": selection.Profile.CorpID,
|
||||
"corpName": selection.Profile.CorpName,
|
||||
"ok": err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
entry["error"] = multiProfileErrorPayload(err)
|
||||
} else {
|
||||
succeeded++
|
||||
if payload := multiProfileResultPayload(result); payload != nil {
|
||||
entry["result"] = payload
|
||||
}
|
||||
if result.Response != nil {
|
||||
if endpoint, ok := result.Response["endpoint"]; ok {
|
||||
entry["endpoint"] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": failed == 0,
|
||||
"multiProfile": true,
|
||||
"summary": map[string]any{
|
||||
"total": len(selections),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
"profiles": entries,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
|
||||
cloned := invocation
|
||||
if invocation.Params != nil {
|
||||
cloned.Params = make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
cloned.Params[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func multiProfileResultPayload(result executor.Result) any {
|
||||
if result.Response == nil {
|
||||
return nil
|
||||
}
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
return result.Response
|
||||
}
|
||||
|
||||
func multiProfileErrorPayload(err error) map[string]any {
|
||||
payload := map[string]any{
|
||||
"message": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
payload["category"] = string(typed.Category)
|
||||
if typed.Reason != "" {
|
||||
payload["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
@@ -259,13 +412,22 @@ 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"}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -324,6 +486,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
|
||||
// surface advertises the resolved MCP arguments without polluting the
|
||||
// stdout payload (which stays valid JSON in --format json mode). Mirrors
|
||||
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
|
||||
// machine-readable channel.
|
||||
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
@@ -483,6 +653,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
// Align with wukong's response envelope: stamp a top-level success=true on
|
||||
// map payloads that don't already carry a success flag. Business errors
|
||||
// (success=false) are intercepted above, so reaching here means the call
|
||||
// succeeded. Additive only — existing keys are never overwritten.
|
||||
if callResult.Content != nil {
|
||||
if _, has := callResult.Content["success"]; !has {
|
||||
callResult.Content["success"] = true
|
||||
}
|
||||
}
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": callResult.Content,
|
||||
@@ -577,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeToken string
|
||||
cachedRuntimeTokenOnce sync.Once
|
||||
cachedRuntimeTokenMu sync.Mutex
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
)
|
||||
|
||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
if cacheKey == "" {
|
||||
cacheKey = "__default__"
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
}
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
cachedRuntimeTokens[cacheKey] = token
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
@@ -613,8 +804,9 @@ func generateExecutionID() string {
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
cachedRuntimeTokenOnce = sync.Once{}
|
||||
cachedRuntimeToken = ""
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
defer cachedRuntimeTokenMu.Unlock()
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
@@ -687,20 +879,21 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
|
||||
// per-(machine × agent_code) instance id. This is what makes agent_code
|
||||
// actually report a value: previously it was sent only when the host
|
||||
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
|
||||
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
|
||||
// per-(machine × agent_code) instance id when a code is known. Synthetic
|
||||
// fallbacks must not be sent because PAT authorization checks use the same
|
||||
// header as their grant key.
|
||||
//
|
||||
// Backward-compat by design (additive, not breaking):
|
||||
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
|
||||
// (set by id.Headers() above), so old/new clients stay comparable.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
|
||||
// Old clients don't send it, which is itself a clean old/new signal.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
|
||||
// sent only when x-dingtalk-dws-agent-code is non-empty.
|
||||
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
|
||||
agentCode, agentCodeSig := authpkg.DetectAgentCode()
|
||||
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
|
||||
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
|
||||
headers["x-dws-agent-instance-id"] = agentInstanceID
|
||||
}
|
||||
|
||||
// Emit the CLI version on the wire so the gateway can segment old vs new
|
||||
// clients (and scope agent_code coverage / adoption). The header constant
|
||||
@@ -708,7 +901,6 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if version != "" {
|
||||
headers[transport.HeaderVersion] = version
|
||||
}
|
||||
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
@@ -738,13 +930,23 @@ func resolveIdentityHeaders() map[string]string {
|
||||
// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP
|
||||
// protocol level. Returns the error message, or "" if the response is OK.
|
||||
func detectBusinessError(content map[string]any) string {
|
||||
return detectBusinessErrorAtDepth(content, 0)
|
||||
}
|
||||
|
||||
func detectBusinessErrorAtDepth(content map[string]any, depth int) string {
|
||||
if content == nil || depth > 8 {
|
||||
return ""
|
||||
}
|
||||
success, ok := content["success"]
|
||||
if !ok {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
b, ok := success.(bool)
|
||||
if !ok || b {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
if nested := detectNestedBusinessError(content, depth); nested != "" {
|
||||
return nested
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return strings.TrimSpace(msg)
|
||||
@@ -755,6 +957,28 @@ func detectBusinessError(content map[string]any) string {
|
||||
return "business error: success=false"
|
||||
}
|
||||
|
||||
func detectNestedBusinessError(content map[string]any, depth int) string {
|
||||
for _, key := range []string{"content", "result", "data"} {
|
||||
switch child := content[key].(type) {
|
||||
case map[string]any:
|
||||
if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
case []any:
|
||||
for _, item := range child {
|
||||
childMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractMCPErrorMessage builds an error message from a ToolCallResult with
|
||||
// isError=true. It extracts text from content blocks when available.
|
||||
func extractMCPErrorMessage(result transport.ToolCallResult) string {
|
||||
|
||||
+24
-10
@@ -320,11 +320,12 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
t.Setenv(authpkg.AgentCodeEnv, " QoderWork ")
|
||||
t.Setenv(authpkg.AgentCodeEnvCompat, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "QoderWork" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want QoderWork", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,14 +377,13 @@ func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
// The reversed env name must never be consumed. With no canonical
|
||||
// declaration and no host signature, agent_code resolves to the honest
|
||||
// "custom" fallback — and crucially is NOT the reversed value.
|
||||
got := headers["x-dingtalk-dws-agent-code"]
|
||||
if got == "compat" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got)
|
||||
// declaration and no host signature, agent_code stays empty rather than
|
||||
// falling back to a synthetic key.
|
||||
if got, ok := headers["x-dingtalk-dws-agent-code"]; ok {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got)
|
||||
}
|
||||
if got != authpkg.AgentCodeCustom {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
|
||||
if got, ok := headers["x-dws-agent-instance-id"]; ok {
|
||||
t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,6 +943,20 @@ func jsonRPCToolName(req map[string]any) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func TestDetectBusinessErrorNestedServiceResult(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"success": false,
|
||||
"result": map[string]any{
|
||||
"success": false,
|
||||
"errorCode": "ROBOT_NOT_FOUND",
|
||||
"errorMsg": "robot info is not exist",
|
||||
},
|
||||
}
|
||||
if got := detectBusinessError(content); got != "robot info is not exist" {
|
||||
t.Fatalf("detectBusinessError() = %q, want nested errorMsg", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
@@ -95,12 +95,13 @@ var agentSkillPaths = map[string]string{
|
||||
// `agents` is the generic-agent sentinel: install scripts and `setup`
|
||||
// special-case ~/.agents/skills as a no-checks-required fallback so a
|
||||
// fresh machine without any IDE/agent registry still gets skills.
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"qoderwork": ".qoderwork/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".github/skills",
|
||||
|
||||
@@ -21,6 +21,8 @@ var skillSetupAgentHomes = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
@@ -56,7 +58,8 @@ multi 模式支持按产品挑选:
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`,
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup # 交互式
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
@@ -68,7 +71,7 @@ multi 模式支持按产品挑选:
|
||||
}
|
||||
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
|
||||
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)")
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
|
||||
cmd.Flags().Bool("yes", false, "跳过所有确认提示")
|
||||
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
|
||||
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
|
||||
@@ -120,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
multiSkillNames = filtered
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
@@ -157,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
|
||||
const multiSkillPrefix = "dingtalk-"
|
||||
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||||
hasShared := false
|
||||
for _, n := range all {
|
||||
if n == multiSharedSkill {
|
||||
hasShared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasShared {
|
||||
return selected
|
||||
}
|
||||
for _, n := range selected {
|
||||
if n == multiSharedSkill {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
return append([]string{multiSharedSkill}, selected...)
|
||||
}
|
||||
|
||||
// normalizeMultiSkillName accepts either the short form (aitable) or the
|
||||
// full form (dingtalk-aitable) and returns the canonical full form.
|
||||
// Empty input returns "". Comparison is case-insensitive.
|
||||
@@ -314,7 +346,31 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
candidates := skillSourceCandidates(explicit, subdir)
|
||||
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
|
||||
// override that does not contain a skill root is an error — never a
|
||||
// silent fallback to another source the user did not ask for.
|
||||
var overrides []string
|
||||
if explicit != "" {
|
||||
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
for _, c := range overrides {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
hint := strings.Join(overrides, "\n - ")
|
||||
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
|
||||
}
|
||||
|
||||
// No explicit override: legacy fallback only — embedded materialization
|
||||
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
|
||||
// the wrapper that callers use. This branch is reachable only when the
|
||||
// wrapper passes through with an empty explicit/env (legacy direct call).
|
||||
candidates := skillSourceCandidates("", subdir)
|
||||
for _, c := range candidates {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
@@ -640,7 +696,14 @@ func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||
}
|
||||
|
||||
func isInteractiveTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
|
||||
}
|
||||
|
||||
func isCharDevice(file *os.File) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
fi, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -47,6 +47,22 @@ func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) {
|
||||
if isCharDevice(nil) {
|
||||
t.Fatal("nil file must not be treated as interactive")
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if isCharDevice(file) {
|
||||
t.Fatal("regular files must not be treated as interactive terminals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
monoDir := filepath.Join(tmp, "skills", "mono")
|
||||
@@ -317,36 +333,6 @@ func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
|
||||
|
||||
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
|
||||
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
|
||||
// successfully discovers ~/.dws/skills/multi/ as the source.
|
||||
func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
|
||||
cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
|
||||
if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run resolver from a tempdir that has no skills/ on disk, simulating a
|
||||
// fresh user machine without a source checkout.
|
||||
scratch := t.TempDir()
|
||||
t.Chdir(scratch)
|
||||
|
||||
got, err := resolveSkillSetupSource("", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
|
||||
}
|
||||
if got != cacheRoot {
|
||||
t.Fatalf("expected %s, got %s", cacheRoot, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMultiSkillName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -15,10 +15,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
data, _ := jsonutil.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
@@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
data, _ := jsonutil.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
data, _ := jsonutil.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
|
||||
@@ -15,20 +15,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ugBold = color.New(color.Bold).SprintFunc()
|
||||
ugGreen = color.New(color.FgGreen).SprintFunc()
|
||||
ugYellow = color.New(color.FgYellow).SprintFunc()
|
||||
ugRed = color.New(color.FgRed).SprintFunc()
|
||||
ugCyan = color.New(color.FgCyan).SprintFunc()
|
||||
ugDim = color.New(color.Faint).SprintFunc()
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
ugBold = tui.Bold
|
||||
ugGreen = tui.Success
|
||||
ugYellow = tui.Warning
|
||||
ugRed = tui.Danger
|
||||
ugCyan = tui.Cyan
|
||||
ugDim = tui.Dim
|
||||
ugBoldGrn = tui.Success
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
// family (VSCODE_BRAND covers every VS Code fork, present and future).
|
||||
// - Every per-host signature below is OBSERVED on a real host (live process
|
||||
// env via `ps eww`, or the app bundle Info.plist), not guessed.
|
||||
// - Anything unidentified falls back to AgentCodeCustom — never guess.
|
||||
// - Anything unidentified stays empty — never guess or synthesize a PAT key.
|
||||
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
|
||||
// not the agent host) and fuzzy parent-process name matching.
|
||||
package auth
|
||||
@@ -33,7 +33,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentCodeCustom is the honest fallback for any host we cannot identify.
|
||||
// AgentCodeCustom is the literal code a host may explicitly declare for a
|
||||
// custom integration. It is not used as an implicit fallback.
|
||||
const AgentCodeCustom = "custom"
|
||||
|
||||
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
|
||||
@@ -66,7 +67,7 @@ var knownSignatures = []hostSignature{
|
||||
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
|
||||
// self-identifying env marker — only user-set API-key/config vars, which we
|
||||
// must not key off (a user setting GEMINI_API_KEY is not "running under
|
||||
// gemini"). They therefore resolve to custom unless they declare themselves.
|
||||
// gemini"). They therefore resolve to empty unless they declare themselves.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
|
||||
@@ -78,7 +79,7 @@ var knownSignatures = []hostSignature{
|
||||
// id is exposed via __CFBundleIdentifier and inherited by child processes the
|
||||
// IDE spawns (including dws), so it identifies the host even from an integrated
|
||||
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
|
||||
// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
|
||||
// bundles map; everything else (iTerm, Terminal, ...) falls through to empty.
|
||||
//
|
||||
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
|
||||
// this map is simply a no-op there (os.Getenv returns "").
|
||||
@@ -96,11 +97,11 @@ var bundleIDToCode = map[string]string{
|
||||
// T1 verified per-agent env signature (CLI/daemon agents)
|
||||
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
|
||||
// T3 macOS app bundle id (known agent bundles only)
|
||||
// T4 fallback -> custom (never guess)
|
||||
// T4 unresolved -> empty (never guess)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return normalizeAgentCode(v), "env:" + name
|
||||
return v, "env:" + name
|
||||
}
|
||||
|
||||
// T1: verified per-agent env signature (most specific — wins over the IDE
|
||||
@@ -127,8 +128,8 @@ func DetectAgentCode() (code string, signal string) {
|
||||
}
|
||||
}
|
||||
|
||||
// T4: unknown host — honest fallback, no guessing.
|
||||
return AgentCodeCustom, "fallback"
|
||||
// T4: unknown host — leave agent_code empty, no guessing.
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
|
||||
@@ -140,11 +141,13 @@ func normalizeAgentCode(raw string) string {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
switch s {
|
||||
case "":
|
||||
return AgentCodeCustom
|
||||
return ""
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder", "qoderwork":
|
||||
case "qoder":
|
||||
return "qoder"
|
||||
case "qoderwork":
|
||||
return "QoderWork"
|
||||
case "workbuddy", "work-buddy":
|
||||
return "workbuddy"
|
||||
case "visualstudiocode", "code", "code-oss", "vscode":
|
||||
|
||||
@@ -38,10 +38,10 @@ func clearAgentCodeEnv(t *testing.T) {
|
||||
|
||||
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(AgentCodeEnv, "Qoder")
|
||||
t.Setenv(AgentCodeEnv, "QoderWork")
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "qoder" {
|
||||
t.Fatalf("want qoder, got %q", code)
|
||||
if code != "QoderWork" {
|
||||
t.Fatalf("want verbatim QoderWork, got %q", code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
|
||||
t.Fatalf("want env signal, got %q", sig)
|
||||
@@ -119,25 +119,24 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
|
||||
// custom.
|
||||
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled.
|
||||
func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("unknown bundle must be custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("unknown bundle must be empty, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
|
||||
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("want custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("want empty code, got %q", code)
|
||||
}
|
||||
if sig != "fallback" {
|
||||
t.Fatalf("want fallback, got %q", sig)
|
||||
if sig != "" {
|
||||
t.Fatalf("want empty signal, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +146,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
|
||||
t.Setenv("TERM_PROGRAM", "iTerm.app")
|
||||
t.Setenv("DWS_CHANNEL", "Qoderwork")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
|
||||
if code != "" {
|
||||
t.Fatalf("noise must not decide agent_code; want empty, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +171,11 @@ func TestNormalizeAgentCode(t *testing.T) {
|
||||
"claude": "claudecode",
|
||||
"Claude-Code": "claudecode",
|
||||
"CLAUDECODE": "claudecode",
|
||||
"Qoderwork": "qoder",
|
||||
"Qoderwork": "QoderWork",
|
||||
"WorkBuddy": "workbuddy",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Cursor": "cursor",
|
||||
"": AgentCodeCustom,
|
||||
"": "",
|
||||
"some-new-ide": "some-new-ide",
|
||||
}
|
||||
for in, want := range cases {
|
||||
|
||||
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
data, err := provider.parseMCPTokenResponse([]byte(`{
|
||||
"accessToken": "access-123",
|
||||
"refreshToken": "refresh-456",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "ding123",
|
||||
"corpName": "钉钉(中国)信息技术有限公司"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpID != "ding123" {
|
||||
t.Fatalf("corp id = %q, want ding123", data.CorpID)
|
||||
}
|
||||
if data.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "snake",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
|
||||
want: "Snake Corp",
|
||||
},
|
||||
{
|
||||
name: "orgName",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
|
||||
want: "Org Corp",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpName != tc.want {
|
||||
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
|
||||
if !strings.Contains(authURL, "corpId=ding-target") {
|
||||
t.Fatalf("auth URL missing target corpId: %s", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
// injects to declare "this process is driven by a third-party Agent host,
|
||||
// render authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
|
||||
// AgentCodeEnvCompat is a compatibility alias for hosts that shipped the
|
||||
// reversed prefix before AgentCodeEnv became the public spelling.
|
||||
AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE"
|
||||
)
|
||||
|
||||
// AgentCodeFromEnv returns the effective host agent code and the env name that
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,13 +147,14 @@ type serviceResult struct {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
// previous login methods (OAuth, PAT, etc.) before device flow falls back to
|
||||
// MCP-managed credentials.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, device flow will re-fetch from MCP and
|
||||
// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
|
||||
// the MCP proxy path (which doesn't require clientSecret).
|
||||
// app.json, device flow will re-fetch from MCP and set the correct
|
||||
// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path
|
||||
// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret
|
||||
// overrides intentionally skip this reset.
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -162,22 +163,29 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This
|
||||
// ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -583,20 +591,21 @@ func truncateBody(body []byte, maxLen int) string {
|
||||
}
|
||||
|
||||
var (
|
||||
dfBold = color.New(color.Bold).SprintFunc()
|
||||
dfGreen = color.New(color.FgGreen).SprintFunc()
|
||||
dfYellow = color.New(color.FgYellow).SprintFunc()
|
||||
dfRed = color.New(color.FgRed).SprintFunc()
|
||||
dfCyan = color.New(color.FgCyan).SprintFunc()
|
||||
dfDim = color.New(color.Faint).SprintFunc()
|
||||
dfBold = tui.Bold
|
||||
dfGreen = tui.Success
|
||||
dfYellow = tui.Warning
|
||||
dfRed = tui.Danger
|
||||
dfCyan = tui.Cyan
|
||||
dfDim = tui.Dim
|
||||
)
|
||||
|
||||
func dfPrintStep(w io.Writer, step int, message string, attempt int) {
|
||||
label := fmt.Sprintf("Step %d", step)
|
||||
if attempt > 1 {
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt)
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)))
|
||||
_, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message)
|
||||
}
|
||||
|
||||
func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
@@ -622,7 +631,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
func dfPrintBox(w io.Writer, lines []string) {
|
||||
maxLen := 0
|
||||
for _, line := range lines {
|
||||
if l := dfPlainLength(line); l > maxLen {
|
||||
if l := tui.PlainRuneWidth(line); l > maxLen {
|
||||
maxLen = l
|
||||
}
|
||||
}
|
||||
@@ -631,38 +640,19 @@ func dfPrintBox(w io.Writer, lines []string) {
|
||||
}
|
||||
|
||||
border := strings.Repeat("─", maxLen+4)
|
||||
_, _ = fmt.Fprintf(w, " ┌%s┐\n", border)
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
|
||||
for _, line := range lines {
|
||||
pad := maxLen - dfPlainLength(line)
|
||||
pad := maxLen - tui.PlainRuneWidth(line)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad))
|
||||
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " └%s┘\n", border)
|
||||
}
|
||||
|
||||
func dfPlainLength(s string) int {
|
||||
inEscape := false
|
||||
length := 0
|
||||
for _, r := range s {
|
||||
if r == '\033' {
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
length++
|
||||
}
|
||||
return length
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
|
||||
}
|
||||
|
||||
func dfPrintPollStatus(w io.Writer, count, elapsedSec int) {
|
||||
_, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
_, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
}
|
||||
|
||||
func dfPrintPollResult(w io.Writer, status, message string) {
|
||||
|
||||
@@ -290,6 +290,11 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) {
|
||||
clientID, clientSecret = getRuntimeCredentials()
|
||||
return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
|
||||
@@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string {
|
||||
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
|
||||
// persisting it on first sight of an agentCode. Idempotent: the same machine
|
||||
// and agentCode always yields the same id, which is what makes cumulative
|
||||
// per-agent_code statistics possible. An empty agentCode is treated as the
|
||||
// custom bucket.
|
||||
// per-agent_code statistics possible. An empty agentCode has no per-agent
|
||||
// identity and returns empty.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
agentCode = AgentCodeCustom
|
||||
return ""
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
|
||||
@@ -74,13 +74,12 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
|
||||
func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
got := id.ResolveAgentID(dir, "", "fallback")
|
||||
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
|
||||
if got != want {
|
||||
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
|
||||
if got != "" {
|
||||
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
@@ -30,6 +31,24 @@ var (
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
|
||||
}
|
||||
|
||||
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
|
||||
func TokenAccountForCorpID(corpID string) string {
|
||||
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
|
||||
}
|
||||
|
||||
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
|
||||
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
|
||||
}
|
||||
|
||||
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal token data: %w", err)
|
||||
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
|
||||
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
||||
return fmt.Errorf("save to keychain: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
|
||||
// LoadTokenDataKeychain loads TokenData from the platform keychain.
|
||||
func LoadTokenDataKeychain() (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
|
||||
return loadTokenDataKeychainAccount(keychain.AccountToken)
|
||||
}
|
||||
|
||||
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
|
||||
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain")
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
|
||||
func DeleteTokenDataKeychainForCorpID(corpID string) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychain checks if token data exists in keychain.
|
||||
func TokenDataExistsKeychain() bool {
|
||||
return keychain.Exists(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
|
||||
func TokenDataExistsKeychainForCorpID(corpID string) bool {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return false
|
||||
}
|
||||
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// EnsureMigration performs one-time migration from legacy .data to keychain.
|
||||
// This should be called early in the auth flow (e.g., during GetAccessToken).
|
||||
// The migration is idempotent and thread-safe.
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -143,9 +144,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -185,9 +190,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -259,7 +268,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// parseMCPTokenResponse parses token response from MCP proxy.
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
||||
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
var resp struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
@@ -267,6 +276,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
PersistentCode string `json:"persistentCode"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
CorpNameSnake string `json:"corp_name"`
|
||||
OrgName string `json:"orgName"`
|
||||
// Error fields (when request fails)
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
@@ -293,6 +305,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
||||
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
||||
CorpID: resp.CorpID,
|
||||
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
|
||||
}
|
||||
if resp.PersistentCode != "" {
|
||||
data.PersistentCode = resp.PersistentCode
|
||||
@@ -300,7 +313,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI string) string {
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -308,6 +330,9 @@ func buildAuthURL(clientID, redirectURI string) string {
|
||||
"scope": {DefaultScopes},
|
||||
"prompt": {"consent"},
|
||||
}
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,13 @@ var oauthHTTPClient = &http.Client{
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -57,8 +58,8 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
// previous login methods before the OAuth flow falls back to MCP-managed
|
||||
// credentials. Complete runtime AppKey/AppSecret overrides skip this reset.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -110,22 +111,29 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we can re-fetch clientID from MCP. This ensures --force
|
||||
// login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
@@ -390,7 +398,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI)
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
@@ -540,9 +548,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
if rErr == nil {
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||
}
|
||||
} else {
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
||||
|
||||
@@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
if TokenDataExistsKeychain() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
@@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
@@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
tokenA := &TokenData{
|
||||
AccessToken: "access-a",
|
||||
RefreshToken: "refresh-a",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_a",
|
||||
CorpName: "A Org",
|
||||
ClientID: "client-a",
|
||||
}
|
||||
tokenB := &TokenData{
|
||||
AccessToken: "access-b",
|
||||
RefreshToken: "refresh-b",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
ClientID: "client-b",
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() after import error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profiles after import = %#v", cfg)
|
||||
}
|
||||
if len(cfg.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
|
||||
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "access-a" {
|
||||
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "access-b" {
|
||||
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
|
||||
// cross-process file lock) so that all read-modify-write cycles on
|
||||
// profiles.json and the legacy token mirror are serialized.
|
||||
//
|
||||
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
|
||||
// calling a public (locking) function from within fn would deadlock. Paths that
|
||||
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
|
||||
// reached from it) must likewise call the lock-free variants directly.
|
||||
func withProfilesLock(configDir string, fn func() error) error {
|
||||
lock, err := AcquireDualLock(context.Background(), configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Release()
|
||||
return fn()
|
||||
}
|
||||
|
||||
const profilesJSONFile = "profiles.json"
|
||||
|
||||
const (
|
||||
ProfileStatusActive = "active"
|
||||
ProfileStatusExpired = "expired"
|
||||
ProfileStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
|
||||
type ProfilesConfig struct {
|
||||
Version int `json:"version"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []Profile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// Profile is a logged-in DingTalk organization identity.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// SetRuntimeProfile sets a process-local one-shot profile override.
|
||||
func SetRuntimeProfile(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
func ProfilesPath(configDir string) string {
|
||||
return filepath.Join(configDir, profilesJSONFile)
|
||||
}
|
||||
|
||||
// LoadProfiles reads profiles.json. A missing file returns an empty config.
|
||||
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
|
||||
path := ProfilesPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profiles: %w", err)
|
||||
}
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
|
||||
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
|
||||
// instead of being permanently locked out by an unreadable profiles.json.
|
||||
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
|
||||
_ = os.Rename(path, quarantine)
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveProfiles writes profiles.json atomically.
|
||||
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
||||
if cfg == nil {
|
||||
cfg = &ProfilesConfig{}
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal profiles: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
path := ProfilesPath(configDir)
|
||||
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
|
||||
// concurrent writers interleave into the same temp file and rename a
|
||||
// corrupted result into place.
|
||||
tmp := path + "." + uuid.New().String() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("write profiles tmp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename profiles: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
|
||||
// EnsureProfilesMigration migrates a legacy single-slot token into the
|
||||
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
|
||||
// from contexts that already hold it (refresh / read paths).
|
||||
func EnsureProfilesMigration(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return ensureProfilesMigrationLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) > 0 {
|
||||
return nil
|
||||
}
|
||||
if !TokenDataExistsKeychain() {
|
||||
return nil
|
||||
}
|
||||
data, err := LoadTokenDataKeychain()
|
||||
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, false)
|
||||
}
|
||||
|
||||
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
||||
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
||||
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
||||
}
|
||||
|
||||
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
|
||||
// the token's corp the persistent current profile.
|
||||
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
|
||||
})
|
||||
}
|
||||
|
||||
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
|
||||
}
|
||||
|
||||
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
idx := profileIndexByCorpID(cfg, corpID)
|
||||
if idx < 0 {
|
||||
profile := Profile{
|
||||
Name: chooseProfileName(cfg, data),
|
||||
CorpID: corpID,
|
||||
CorpName: strings.TrimSpace(data.CorpName),
|
||||
UserID: strings.TrimSpace(data.UserID),
|
||||
UserName: strings.TrimSpace(data.UserName),
|
||||
ClientID: strings.TrimSpace(data.ClientID),
|
||||
Status: ProfileStatusActive,
|
||||
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
|
||||
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
|
||||
LastLoginAt: now,
|
||||
LastUsedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, profile)
|
||||
} else {
|
||||
p := &cfg.Profiles[idx]
|
||||
if shouldRefreshProfileName(p, data) {
|
||||
p.Name = chooseProfileName(cfg, data)
|
||||
}
|
||||
if v := strings.TrimSpace(data.CorpName); v != "" {
|
||||
p.CorpName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserID); v != "" {
|
||||
p.UserID = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserName); v != "" {
|
||||
p.UserName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.ClientID); v != "" {
|
||||
p.ClientID = v
|
||||
}
|
||||
p.Status = ProfileStatusActive
|
||||
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
|
||||
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
|
||||
p.LastLoginAt = now
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = corpID
|
||||
}
|
||||
if makeCurrent && cfg.CurrentProfile != corpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
|
||||
func ResolveProfile(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SetCurrentProfile persists the selected current profile.
|
||||
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := setCurrentProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
if cfg.CurrentProfile != p.CorpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = p.CorpID
|
||||
}
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// UsePreviousProfile toggles currentProfile and previousProfile.
|
||||
func UsePreviousProfile(configDir string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := usePreviousProfileLocked(configDir)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev := strings.TrimSpace(cfg.PreviousProfile)
|
||||
if prev == "" {
|
||||
return nil, fmt.Errorf("previous profile is empty")
|
||||
}
|
||||
p := findProfile(cfg, prev)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("previous profile %q not found", prev)
|
||||
}
|
||||
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// RemoveProfile removes a profile from metadata and returns the removed profile.
|
||||
func RemoveProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := removeProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
removed := *p
|
||||
kept := cfg.Profiles[:0]
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID != removed.CorpID {
|
||||
kept = append(kept, profile)
|
||||
}
|
||||
}
|
||||
cfg.Profiles = kept
|
||||
if cfg.PrimaryProfile == removed.CorpID {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == removed.CorpID {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.PreviousProfile == removed.CorpID {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
cfg.PrimaryProfile = ""
|
||||
cfg.CurrentProfile = ""
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &removed, nil
|
||||
}
|
||||
|
||||
// MarkProfileStatus updates a profile status if it exists.
|
||||
func MarkProfileStatus(configDir, corpID, status string) error {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return nil
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return markProfileStatusLocked(configDir, corpID, status)
|
||||
})
|
||||
}
|
||||
|
||||
func markProfileStatusLocked(configDir, corpID, status string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := findProfile(cfg, corpID)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Status = strings.TrimSpace(status)
|
||||
p.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
|
||||
func SyncLegacyTokenMirror(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return syncLegacyTokenMirrorLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func syncLegacyTokenMirrorLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hadReadError := false
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
p := findProfile(cfg, candidate)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
|
||||
if loadErr != nil {
|
||||
// Transient keychain read failure: do NOT touch the existing mirror.
|
||||
hadReadError = true
|
||||
continue
|
||||
}
|
||||
if data != nil {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
}
|
||||
if hadReadError {
|
||||
// Keep the existing legacy mirror untouched rather than wiping a host
|
||||
// app's login state just because keychain was momentarily unavailable.
|
||||
return nil
|
||||
}
|
||||
// All candidate profiles confirmed absent (no token): clear the mirror.
|
||||
_ = DeleteTokenDataKeychain()
|
||||
_ = DeleteTokenMarker(configDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.Version = 1
|
||||
seen := make(map[string]bool, len(cfg.Profiles))
|
||||
profiles := cfg.Profiles[:0]
|
||||
for _, p := range cfg.Profiles {
|
||||
p.CorpID = strings.TrimSpace(p.CorpID)
|
||||
if p.CorpID == "" || seen[p.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[p.CorpID] = true
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
p.Name = p.CorpID
|
||||
}
|
||||
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
|
||||
p.Name = corpName
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = ProfileStatusActive
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
cfg.Profiles = profiles
|
||||
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
|
||||
cfg.PrimaryProfile = ""
|
||||
}
|
||||
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
}
|
||||
}
|
||||
|
||||
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
|
||||
base := strings.TrimSpace(data.CorpName)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(data.CorpID)
|
||||
}
|
||||
if base == "" {
|
||||
base = "profile"
|
||||
}
|
||||
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
|
||||
return base
|
||||
}
|
||||
suffix := shortCorpID(data.CorpID)
|
||||
name := base + "-" + suffix
|
||||
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
|
||||
return name
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
|
||||
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
|
||||
if p == nil || data == nil {
|
||||
return false
|
||||
}
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for _, p := range cfg.Profiles {
|
||||
if p.CorpID != corpID && p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return nil
|
||||
}
|
||||
var corpNameMatch *Profile
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
|
||||
return &cfg.Profiles[i]
|
||||
}
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
||||
if corpNameMatch != nil {
|
||||
return nil
|
||||
}
|
||||
corpNameMatch = &cfg.Profiles[i]
|
||||
}
|
||||
}
|
||||
return corpNameMatch
|
||||
}
|
||||
|
||||
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
|
||||
if cfg == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func firstProfileCorpID(cfg *ProfilesConfig) string {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return cfg.Profiles[0].CorpID
|
||||
}
|
||||
|
||||
func touchProfile(cfg *ProfilesConfig, corpID string) {
|
||||
if p := findProfile(cfg, corpID); p != nil {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func timeOrRFC3339(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func shortCorpID(corpID string) string {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if len(corpID) <= 8 {
|
||||
return corpID
|
||||
}
|
||||
return corpID[len(corpID)-8:]
|
||||
}
|
||||
+167
-10
@@ -22,8 +22,11 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
|
||||
|
||||
// DeleteTokenMarker removes the token.json marker file.
|
||||
func DeleteTokenMarker(configDir string) error {
|
||||
return os.Remove(filepath.Join(configDir, tokenJSONFile))
|
||||
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
||||
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
|
||||
// to the default keychain-based storage.
|
||||
func SaveTokenData(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
return SaveTokenDataKeychain(data)
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return saveTokenDataLocked(configDir, data)
|
||||
})
|
||||
}
|
||||
|
||||
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
|
||||
// writes assuming the auth dual-layer lock is already held. Callers that
|
||||
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
|
||||
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
|
||||
// to avoid deadlocking on the non-reentrant lock.
|
||||
func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
if data != nil && strings.TrimSpace(data.CorpID) != "" {
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
|
||||
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
if makeCurrent {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
|
||||
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
}
|
||||
|
||||
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain with legacy .data migration.
|
||||
func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
return LoadTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
|
||||
// currentProfile. Empty selector follows the default resolution chain.
|
||||
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if h := edition.Get(); h.LoadToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
jsonData, err := h.LoadToken(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// Default: keychain with legacy .data migration
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected != nil {
|
||||
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
// profile. Only fall back to the legacy single slot when it belongs to
|
||||
// the SAME org; otherwise surface the error instead of silently acting
|
||||
// as a different organization (the legacy mirror may have drifted).
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if TokenDataExistsKeychain() {
|
||||
return LoadTokenDataKeychain()
|
||||
}
|
||||
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err == nil {
|
||||
// One-time legacy secure-store -> keychain migration. This read path may run
|
||||
// while the refresh lock is already held, so use the lock-free saver.
|
||||
if err := saveTokenDataLocked(configDir, data); err == nil {
|
||||
_ = DeleteSecureData(configDir)
|
||||
}
|
||||
return data, nil
|
||||
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain + legacy cleanup.
|
||||
func DeleteTokenData(configDir string) error {
|
||||
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
|
||||
// removes the current/default profile, falling back to legacy single-slot auth.
|
||||
func DeleteTokenDataForProfile(configDir, profile string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return deleteTokenDataForProfileLocked(configDir, profile)
|
||||
})
|
||||
}
|
||||
|
||||
func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selected != nil {
|
||||
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
|
||||
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
|
||||
legacyErr := syncLegacyTokenMirrorLocked(configDir)
|
||||
secureErr := DeleteSecureData(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
if removeErr != nil {
|
||||
return removeErr
|
||||
}
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return secureErr
|
||||
}
|
||||
|
||||
keychainErr := DeleteTokenDataKeychain()
|
||||
legacyErr := DeleteSecureData(configDir)
|
||||
markerErr := DeleteTokenMarker(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
return legacyErr
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return markerErr
|
||||
}
|
||||
|
||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||
func DeleteAllTokenData(configDir string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
var firstErr error
|
||||
// Best-effort: even if profiles.json is unreadable, still clear every
|
||||
// other slot so the user can always self-heal via auth reset / logout.
|
||||
if cfg, err := LoadProfiles(configDir); err == nil {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
|
||||
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
|
||||
for _, m := range matches {
|
||||
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
return firstErr
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,8 +26,10 @@ import (
|
||||
// written by these tests, and removes test data on completion.
|
||||
func cleanupKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
SetRuntimeProfile("")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
SetRuntimeProfile("")
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
}
|
||||
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
|
||||
loadedB, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
loadedA, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after switch error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
if _, err := UsePreviousProfile(configDir); err != nil {
|
||||
t.Fatalf("UsePreviousProfile() error = %v", err)
|
||||
}
|
||||
loadedB, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after previous error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp_b")
|
||||
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
|
||||
}
|
||||
SetRuntimeProfile("")
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b_refreshed" {
|
||||
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
|
||||
}
|
||||
loadedDefault, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedDefault.AccessToken != "at_a" {
|
||||
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
|
||||
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
|
||||
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("profiles after delete = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "旧组织名")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
second.UserID = "user_updated"
|
||||
second.UserName = "Updated User"
|
||||
second.ClientID = "client_updated"
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
profile := cfg.Profiles[0]
|
||||
if profile.CorpName != "新组织名" {
|
||||
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
|
||||
}
|
||||
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
|
||||
t.Fatalf("profile metadata was not overwritten: %#v", profile)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_second" {
|
||||
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
|
||||
resolved, err := ResolveProfile(configDir, "新组织名")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfile(corpName) error = %v", err)
|
||||
}
|
||||
if resolved.CorpID != "corp_same" {
|
||||
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
raw := `{
|
||||
"version": 1,
|
||||
"primaryProfile": "corp_same",
|
||||
"currentProfile": "corp_same",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "corp_same",
|
||||
"corpId": "corp_same",
|
||||
"corpName": "新组织名"
|
||||
}
|
||||
]
|
||||
}`
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profiles.json) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
|
||||
if err := SaveTokenDataKeychain(legacy); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_legacy" {
|
||||
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
|
||||
t.Fatalf("profile pointers after migration = %#v", cfg)
|
||||
}
|
||||
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
|
||||
t.Fatal("corp-scoped token should exist after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
|
||||
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testToken(accessToken, corpID, corpName string) *TokenData {
|
||||
now := time.Now().UTC()
|
||||
return &TokenData{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "rt_" + accessToken,
|
||||
ExpiresAt: now.Add(2 * time.Hour),
|
||||
RefreshExpAt: now.Add(30 * 24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: "user_" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client_" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidityChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
|
||||
@@ -125,7 +125,13 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
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`,
|
||||
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" # 列出该分组下的子命令`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -137,6 +143,29 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
}
|
||||
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 {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
var degraded *CatalogDegraded
|
||||
@@ -166,6 +195,17 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
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),
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
}}
|
||||
|
||||
t.Run("resolves via --cli-path", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -310,7 +310,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("rejects positional + flag collision", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -1252,7 +1252,7 @@ func TestSchemaCommandOutputsDegradedOnUnauthenticated(t *testing.T) {
|
||||
Reason: DegradedUnauthenticated,
|
||||
Hint: "未登录,无法发现 MCP 服务。请先执行: dws auth login",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1285,9 +1285,9 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) {
|
||||
|
||||
degradedErr := &CatalogDegraded{
|
||||
Reason: DegradedMarketUnreachable,
|
||||
Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络",
|
||||
Hint: "无法连接 MCP 市场,请检查网络",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1310,7 +1310,7 @@ func TestSchemaCommandPropagatesNonDegradedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantErr := errors.New("unexpected failure")
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr}, nil)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// helperSchemaRoots are top-level command names whose subtrees are helper-only
|
||||
// (hard-coded cobra commands, not in the discovery catalog). `dws schema` still
|
||||
// answers for them, but unlike discovery products the schema CONTENT is fetched
|
||||
// LIVE from the helper's pinned MCP server (op-app) and rendered in the flat
|
||||
// gws-aligned shape — never synthesized from local cobra flags, never
|
||||
// hardcoded. The mapping from a leaf command to its MCP tool comes from the
|
||||
// `mcp-tool` cobra annotation set in internal/helpers/devapp.go.
|
||||
var helperSchemaRoots = map[string]bool{"dev": true}
|
||||
|
||||
// HelperToolSchema is the live op-app tool schema the renderer needs: the raw
|
||||
// MCP description plus the inputSchema's properties/required, exactly as the
|
||||
// server returned them (no local transformation of CONTENT).
|
||||
type HelperToolSchema struct {
|
||||
Name string
|
||||
Description string
|
||||
Properties map[string]any // MCP param name → property object {type,description,default?,...}
|
||||
Required []string // MCP param names that are required
|
||||
}
|
||||
|
||||
// HelperToolFetcher loads a helper MCP server's tools/list LIVE and returns
|
||||
// toolName→schema for the given source (e.g. "op-app" for dev app commands,
|
||||
// "devdoc" for dev doc commands). The schema command injects this so
|
||||
// dev_schema.go can resolve a command's MCP tool and render its real schema
|
||||
// without the cli package importing app/transport. Implementations should
|
||||
// cache per-source per-process so repeated `dws schema dev.*` only hit the
|
||||
// network once per source.
|
||||
type HelperToolFetcher func(ctx context.Context, source string) (map[string]HelperToolSchema, error)
|
||||
|
||||
// renderHelperSchema builds the `dws schema` payload for helper-only command
|
||||
// subtrees. Returns (payload, true) when the path targets a helper subtree (so
|
||||
// the caller skips catalog resolution); (nil, false) otherwise so the caller
|
||||
// falls back to the discovery catalog.
|
||||
//
|
||||
// Leaf commands render the gws-flat object {description, path, source,
|
||||
// parameters{<kebab>:{type,description,default?,required}}} with all CONTENT
|
||||
// pulled live from the MCP tool named by the command's `mcp-tool` annotation.
|
||||
// Group/root paths render a browse listing {path, commands:[...]} from the
|
||||
// cobra tree (no MCP needed).
|
||||
func renderHelperSchema(ctx context.Context, root *cobra.Command, rawPath string, fetch HelperToolFetcher) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !helperSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Find resolves to the deepest matching command and returns trailing tokens
|
||||
// it couldn't match as (sub)commands. Any non-flag leftover means a typo'd
|
||||
// or unknown subcommand — surface it with the closest group's children.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// A runnable leaf → emit its live MCP schema in gws-flat shape.
|
||||
// A group → browse its subcommands.
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
payload, err := helperLeafSchema(ctx, target, fetch)
|
||||
return payload, true, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// helperLeafSchema renders a single leaf command as the gws-flat object,
|
||||
// fetching its MCP tool schema live. The command must carry an `mcp-tool`
|
||||
// annotation; commands without one (e.g. `dev connect`, `dev doc search`) are
|
||||
// not devapp tools and get a clear, non-fatal explanation instead.
|
||||
func helperLeafSchema(ctx context.Context, cmd *cobra.Command, fetch HelperToolFetcher) (map[string]any, error) {
|
||||
toolName, source := "", ""
|
||||
if cmd.Annotations != nil {
|
||||
toolName = strings.TrimSpace(cmd.Annotations["mcp-tool"])
|
||||
source = strings.TrimSpace(cmd.Annotations["mcp-source"])
|
||||
}
|
||||
// Default source is op-app (dev app commands); dev doc commands annotate
|
||||
// mcp-source=devdoc to pull from the devdoc MCP server instead.
|
||||
if source == "" {
|
||||
source = "op-app"
|
||||
}
|
||||
path := helperCommandPath(cmd)
|
||||
if toolName == "" {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "no MCP tool bound to this command; schema is unavailable",
|
||||
}, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "live MCP schema fetcher not configured",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tools, err := fetch(ctx, source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s tool schemas: %w", source, err)
|
||||
}
|
||||
tool, ok := tools[toolName]
|
||||
if !ok {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": fmt.Sprintf("MCP tool %q not found in %s tools/list", toolName, source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"description": tool.Description,
|
||||
"path": path,
|
||||
"source": "mcp:" + source,
|
||||
"parameters": helperFlatParameters(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helperFlatParameters projects an MCP tool's inputSchema into the gws-flat
|
||||
// per-parameter object. Keys are kebab-case of the MCP param name (== the CLI
|
||||
// flag); each value is {type, description, default?, required} with type mapped
|
||||
// to a JSON-type string, description verbatim from MCP, default only when MCP
|
||||
// provides one (stringified), and required inline (true iff the param is in the
|
||||
// tool's required[]).
|
||||
func helperFlatParameters(tool HelperToolSchema) map[string]any {
|
||||
required := make(map[string]bool, len(tool.Required))
|
||||
for _, r := range tool.Required {
|
||||
required[r] = true
|
||||
}
|
||||
|
||||
params := make(map[string]any, len(tool.Properties))
|
||||
for name, raw := range tool.Properties {
|
||||
prop, _ := raw.(map[string]any)
|
||||
entry := map[string]any{
|
||||
"type": mcpJSONType(prop),
|
||||
"description": mcpString(prop, "description"),
|
||||
"required": required[name],
|
||||
}
|
||||
if def, ok := mcpDefault(prop); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[kebabCase(name)] = entry
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// mcpJSONType normalizes the MCP property "type" to a JSON-type string. MCP
|
||||
// reports standard JSON Schema types; pass them through, defaulting to "string"
|
||||
// when absent/unknown so the contract is always populated.
|
||||
func mcpJSONType(prop map[string]any) string {
|
||||
t, _ := prop["type"].(string)
|
||||
switch t {
|
||||
case "string", "integer", "number", "boolean", "array", "object":
|
||||
return t
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// mcpString reads a string field from an MCP property object.
|
||||
func mcpString(prop map[string]any, key string) string {
|
||||
if prop == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := prop[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mcpDefault returns the MCP-provided default, stringified, only when present.
|
||||
// gws renders default as a string; mirror that. Non-string JSON defaults
|
||||
// (numbers/bools) are formatted with %v so e.g. 0 → "0", true → "true".
|
||||
func mcpDefault(prop map[string]any) (string, bool) {
|
||||
if prop == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := prop["default"]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
return tv, true
|
||||
case float64:
|
||||
// JSON numbers decode to float64; render integers without a fraction.
|
||||
if tv == float64(int64(tv)) {
|
||||
return fmt.Sprintf("%d", int64(tv)), true
|
||||
}
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
default:
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
}
|
||||
}
|
||||
|
||||
// kebabCase converts an MCP camelCase param name to the CLI flag's kebab form,
|
||||
// matching how flags are registered in internal/helpers/devapp.go:
|
||||
//
|
||||
// eventCallbackUrl → event-callback-url
|
||||
// unifiedAppId → unified-app-id
|
||||
// disableSSLVerify → disable-ssl-verify
|
||||
//
|
||||
// A boundary is inserted before an uppercase letter that follows a lowercase
|
||||
// letter or digit, and before the final uppercase of a run that starts a new
|
||||
// lowercase word (so SSLVerify → ssl-verify, not s-s-l-verify).
|
||||
func kebabCase(name string) string {
|
||||
runes := []rune(name)
|
||||
var b strings.Builder
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) {
|
||||
prevLowerOrDigit := i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1]))
|
||||
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
if i > 0 && (prevLowerOrDigit || nextLower) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
// Collapse any accidental double dashes and trim, just in case the source
|
||||
// already contained separators.
|
||||
out := strings.ReplaceAll(b.String(), "_", "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
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 {
|
||||
out := []map[string]any{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"cli_path": helperCommandPath(sub),
|
||||
"description": strings.TrimSpace(sub.Short),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["cli_path"].(string) < out[j]["cli_path"].(string)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// helperCommandPath returns the space-joined path from root to cmd, e.g.
|
||||
// "dev app robot config".
|
||||
func helperCommandPath(cmd *cobra.Command) string {
|
||||
parts := []string{}
|
||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||
parts = append([]string{c.Name()}, parts...)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// firstNonFlag returns the first token that is not a flag (does not start with
|
||||
// "-"), or "" if there is none.
|
||||
func firstNonFlag(tokens []string) string {
|
||||
for _, t := range tokens {
|
||||
if t != "" && !strings.HasPrefix(t, "-") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildHelperTestTree mirrors the shape of the real `dws dev` subtree closely
|
||||
// enough to exercise the live-schema renderer: a group and leaves carrying the
|
||||
// `mcp-tool` annotation that names the op-app tool to fetch.
|
||||
func buildHelperTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Annotations: map[string]string{"mcp-tool": "create_dev_app"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
config := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置机器人",
|
||||
Annotations: map[string]string{"mcp-tool": "set_extension_robot_config"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
// A leaf without an mcp-tool annotation (e.g. dev connect / dev doc search).
|
||||
noTool := &cobra.Command{Use: "connect", Short: "无 MCP 工具", Run: func(*cobra.Command, []string) {}}
|
||||
|
||||
robot := &cobra.Command{Use: "robot", Short: "机器人能力"}
|
||||
robot.AddCommand(config)
|
||||
|
||||
app := &cobra.Command{Use: "app", Short: "应用"}
|
||||
app.AddCommand(create, robot)
|
||||
|
||||
dev := &cobra.Command{Use: "dev", Short: "开放平台开发者命令"}
|
||||
dev.AddCommand(app, noTool)
|
||||
|
||||
root.AddCommand(dev)
|
||||
return root
|
||||
}
|
||||
|
||||
// fakeFetcher returns a canned op-app tools/list so the renderer is exercised
|
||||
// without network. It mirrors the MCP shape: properties keyed by camelCase param
|
||||
// name, required[] listing the camelCase names.
|
||||
func fakeFetcher(tools map[string]HelperToolSchema) HelperToolFetcher {
|
||||
return func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return tools, nil
|
||||
}
|
||||
}
|
||||
|
||||
func robotConfigToolSchema() HelperToolSchema {
|
||||
return HelperToolSchema{
|
||||
Name: "set_extension_robot_config",
|
||||
Description: "创建或更新现有应用的机器人配置",
|
||||
Properties: map[string]any{
|
||||
"unifiedAppId": map[string]any{"type": "string", "description": "统一应用 ID"},
|
||||
"eventCallbackUrl": map[string]any{"type": "string", "description": "事件回调地址"},
|
||||
"skills": map[string]any{"type": "array", "description": "技能列表"},
|
||||
"mode": map[string]any{"type": "string", "description": "机器人模式", "enum": []any{"HTTPS", "STREAM", "AISKILL"}},
|
||||
},
|
||||
Required: []string{"unifiedAppId"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_LeafGwsFlat(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
fetch := fakeFetcher(map[string]HelperToolSchema{
|
||||
"set_extension_robot_config": robotConfigToolSchema(),
|
||||
})
|
||||
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app robot config", fetch)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected helper renderer to claim the path")
|
||||
}
|
||||
|
||||
// Flat top-level: description / path / source / parameters; no wrapper.
|
||||
if payload["description"] != "创建或更新现有应用的机器人配置" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "dev app robot config" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "mcp:op-app" {
|
||||
t.Fatalf("source = %v", payload["source"])
|
||||
}
|
||||
for _, leaked := range []string{"kind", "tool", "product", "helper"} {
|
||||
if _, present := payload[leaked]; present {
|
||||
t.Fatalf("gws-flat output must not carry %q wrapper key", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Keys are kebab-case of the MCP param name == the CLI flag.
|
||||
uid, _ := params["unified-app-id"].(map[string]any)
|
||||
if uid == nil {
|
||||
t.Fatalf("missing unified-app-id param: %#v", params)
|
||||
}
|
||||
if uid["type"] != "string" || uid["required"] != true {
|
||||
t.Fatalf("unified-app-id = %#v, want string+required", uid)
|
||||
}
|
||||
if _, hasDefault := uid["default"]; hasDefault {
|
||||
t.Fatal("unified-app-id must not carry a default (MCP provides none)")
|
||||
}
|
||||
|
||||
cb, _ := params["event-callback-url"].(map[string]any)
|
||||
if cb == nil || cb["required"] != false {
|
||||
t.Fatalf("event-callback-url = %#v, want required=false", cb)
|
||||
}
|
||||
|
||||
skills, _ := params["skills"].(map[string]any)
|
||||
if skills == nil || skills["type"] != "array" {
|
||||
t.Fatalf("skills = %#v, want array", skills)
|
||||
}
|
||||
|
||||
mode, _ := params["mode"].(map[string]any)
|
||||
if mode == nil || mode["type"] != "string" {
|
||||
t.Fatalf("mode = %#v, want string", mode)
|
||||
}
|
||||
if _, hasDefault := mode["default"]; hasDefault {
|
||||
t.Fatalf("mode default = %v, want none", mode["default"])
|
||||
}
|
||||
if mode["required"] != false {
|
||||
t.Fatalf("mode required = %v, want false", mode["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_Group(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["path"] != "dev app" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
if len(cmds) != 2 { // create + robot
|
||||
t.Fatalf("commands count = %d, want 2", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NoAnnotation(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev connect", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected a clear no-MCP-tool error, got %#v", payload)
|
||||
}
|
||||
if _, present := payload["parameters"]; present {
|
||||
t.Fatal("no-tool command must not emit parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app nope", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected error for unknown subcommand, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_ToolMissingInList(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
// Fetcher returns an empty list — the annotated tool isn't present.
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app create", fakeFetcher(map[string]HelperToolSchema{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected not-found error, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_FetchError(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
failing := func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return nil, errors.New("network down")
|
||||
}
|
||||
_, ok, err := renderHelperSchema(context.Background(), root, "dev app create", failing)
|
||||
if !ok {
|
||||
t.Fatal("expected claim even on fetch error")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the fetch error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NonHelperPathDeclined(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
if _, ok, _ := renderHelperSchema(context.Background(), root, "ding.message.send", nil); ok {
|
||||
t.Fatal("non-helper path must not be claimed by the helper renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKebabCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eventCallbackUrl": "event-callback-url",
|
||||
"unifiedAppId": "unified-app-id",
|
||||
"disableSSLVerify": "disable-ssl-verify",
|
||||
"mode": "mode",
|
||||
"skills": "skills",
|
||||
"i18nName": "i18n-name",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := kebabCase(in); got != want {
|
||||
t.Errorf("kebabCase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
if embedded {
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
}
|
||||
return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络"
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
case DegradedRuntimeAllFailed:
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
@@ -279,7 +279,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL)
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers)
|
||||
|
||||
@@ -18,6 +18,7 @@ package cobracmd
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
|
||||
|
||||
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
|
||||
func NewGroupCommand(use, short string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Tag as a group container: its RunE only prints help, so cobra's
|
||||
// Runnable() can't distinguish it from a real leaf — callers that need to
|
||||
// collapse empty groups rely on this annotation.
|
||||
cmdutil.MarkGroup(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
|
||||
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
|
||||
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
|
||||
//
|
||||
// existingTools maps CLI server ID (slug) → set of tool names that server
|
||||
// actually exposes (from the live tools/list cache). When a server's set is
|
||||
// present and non-empty, override leaves whose backing MCP tool is missing from
|
||||
// it are hidden from `--help` (phantom-command guard). When the set is absent or
|
||||
// empty (cold cache, or a server we have no tools snapshot for) the guard does
|
||||
// nothing for that server, so an unpopulated cache never blanks the command
|
||||
// tree. Pass nil to disable the guard entirely.
|
||||
//
|
||||
// Conversion rules reference: docs/mcp-to-cli-conversion.md
|
||||
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
|
||||
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
|
||||
type builtCmd struct {
|
||||
cmd *cobra.Command
|
||||
parent string // cli.Parent: attach as sub-command of this top-level command
|
||||
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
|
||||
cmd := NewDirectCommand(route, runner)
|
||||
|
||||
// §guard.toolexists: keep `--help` honest under envelope/deployment
|
||||
// drift. When we know the resolved server's live tool set and it does
|
||||
// NOT contain this leaf's backing tool, the command is a phantom
|
||||
// (renders in help but fails at invocation with "tool not found"), so
|
||||
// hide it. Safety rails:
|
||||
// - only acts when the set is KNOWN and non-empty (absent/empty =
|
||||
// unknown = keep; a cold tools cache must never blank the tree);
|
||||
// - skips pipeline leaves, which orchestrate multiple tools and have
|
||||
// no single backing toolName to check;
|
||||
// - Hidden (not removed) so the command stays invocable for anyone
|
||||
// who calls it directly — it just leaves the help surface.
|
||||
if len(override.Pipeline) == 0 && existingTools != nil {
|
||||
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
|
||||
if _, exists := known[toolName]; !exists {
|
||||
cmd.Hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
|
||||
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
|
||||
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
|
||||
@@ -239,6 +266,20 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
// todo_hooks.go for the full rationale). No-op for non-todo.
|
||||
installTodoHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §calendar-hook: reject partial recurrence on event create/update
|
||||
// (see calendar_hooks.go). No-op for non-calendar.
|
||||
installCalendarHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §attendance-hook: inner-JSON required fields, group type and
|
||||
// FIXED conditional checks (see attendance_hooks.go). No-op for
|
||||
// non-attendance.
|
||||
installAttendanceHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §report-hook: native --contents-file / --contents - (stdin)
|
||||
// resolution + one-of(contents, contents-file) relaxation (see
|
||||
// report_hooks.go). No-op for non-report.
|
||||
installReportHook(cmd, canonicalProduct, toolName)
|
||||
|
||||
// §1.4: Add to the right parent group
|
||||
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
|
||||
}
|
||||
@@ -305,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
for _, name := range topOrder {
|
||||
commands = append(commands, topLevel[name])
|
||||
}
|
||||
|
||||
// §guard.emptygroups: a group whose every leaf is hidden would still show in
|
||||
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
|
||||
// tools are gone). Collapse those. This runs unconditionally because leaves
|
||||
// get hidden by TWO independent mechanisms — the runtime tool-existence
|
||||
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
|
||||
// group must collapse even when the guard is inert (cold tools cache). It is
|
||||
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
|
||||
// of whose children are already hidden; it never hides a leaf, so it cannot
|
||||
// blank a tree on its own.
|
||||
for _, c := range commands {
|
||||
// Collapse empty sub-groups within each product, but never the product
|
||||
// root itself: a root can legitimately be empty at this point and gain
|
||||
// visible leaves later from helper/overlay merges, so hiding it here
|
||||
// could wrongly drop a whole product from `dws --help`.
|
||||
for _, sub := range c.Commands() {
|
||||
hideEmptyGroups(sub)
|
||||
}
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
// hideEmptyGroups recursively hides group commands whose every subcommand is
|
||||
// hidden — the collateral of the tool-existence guard emptying a group of all
|
||||
// its leaves. Returns true if cmd is (now) hidden. A command with no
|
||||
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
|
||||
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
|
||||
// retaining at least one visible leaf always stays visible.
|
||||
func hideEmptyGroups(cmd *cobra.Command) bool {
|
||||
subs := cmd.Commands()
|
||||
if len(subs) == 0 {
|
||||
// No children. A real leaf stands on its own Hidden flag. A group
|
||||
// container with no children is empty — this happens when every
|
||||
// override in a group is `hidden:true` (those leaves are never built,
|
||||
// leaving the group childless) — so hide it. Group containers and leaves
|
||||
// both have a RunE, so cobra's Runnable() can't tell them apart; the
|
||||
// group annotation can.
|
||||
if cmdutil.IsGroup(cmd) {
|
||||
cmd.Hidden = true
|
||||
return true
|
||||
}
|
||||
return cmd.Hidden
|
||||
}
|
||||
allHidden := true
|
||||
for _, sub := range subs {
|
||||
if !hideEmptyGroups(sub) {
|
||||
allHidden = false
|
||||
}
|
||||
}
|
||||
if allHidden {
|
||||
cmd.Hidden = true
|
||||
}
|
||||
return cmd.Hidden
|
||||
}
|
||||
|
||||
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
|
||||
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
|
||||
idx := make(map[string]market.DetailTool, len(tools))
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
|
||||
// Should produce only one top-level command: "chat"
|
||||
if len(cmds) != 1 {
|
||||
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
|
||||
// Parent not found, should fall back to top-level
|
||||
if len(cmds) != 1 {
|
||||
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("expected 1 cmd, got %d", len(cmds))
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, captured, nil)
|
||||
cmds := BuildDynamicCommands(servers, captured, nil, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
cmds := BuildDynamicCommands(servers, captured, nil)
|
||||
cmds := BuildDynamicCommands(servers, captured, nil, nil)
|
||||
article := findChild(cmds[0], "article")
|
||||
if article == nil {
|
||||
t.Fatal("article group not found")
|
||||
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
leaf := findChild(cmds[0], "do")
|
||||
if leaf == nil {
|
||||
t.Fatal("do leaf not found")
|
||||
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, captured, nil)
|
||||
cmds := BuildDynamicCommands(servers, captured, nil, nil)
|
||||
leaf := findChild(cmds[0], "bot-list")
|
||||
if leaf == nil {
|
||||
t.Fatal("bot-list leaf not found")
|
||||
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, captured, nil)
|
||||
cmds := BuildDynamicCommands(servers, captured, nil, nil)
|
||||
leaf := findChild(cmds[0], "list")
|
||||
if leaf == nil {
|
||||
t.Fatal("list leaf not found")
|
||||
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
|
||||
send := findChild(cmds[0], "send")
|
||||
if send == nil {
|
||||
t.Fatal("send leaf not found")
|
||||
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
create := findChild(cmds[0], "create")
|
||||
if create == nil {
|
||||
t.Fatal("create leaf not found")
|
||||
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
cmds[0].SetArgs([]string{"list"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"list", "--group", "g1"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
history := findChild(cmds[0], "history")
|
||||
if history == nil {
|
||||
t.Fatal("history stub not found")
|
||||
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
root := cmds[0]
|
||||
|
||||
// history hint attached directly under root.
|
||||
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
list := findChild(cmds[0], "list")
|
||||
if list == nil {
|
||||
t.Fatal("list leaf not found (constraint validation must not abort build)")
|
||||
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"search", "--query", "hello"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"search"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
|
||||
|
||||
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
|
||||
// should build and execute fine.
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
|
||||
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
approval := findChild(cmds[0], "approval")
|
||||
if approval == nil {
|
||||
t.Fatal("approval group not found")
|
||||
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
leaf := findChild(cmds[0], "list-forms")
|
||||
if leaf == nil {
|
||||
t.Fatal("list-forms leaf not found")
|
||||
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
leaf := findChild(cmds[0], "list-forms")
|
||||
if leaf == nil {
|
||||
t.Fatal("list-forms leaf not found")
|
||||
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
if len(cmds) != 1 || cmds[0].Name() != "chat" {
|
||||
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
|
||||
}
|
||||
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
if len(cmds) != 1 || cmds[0].Name() != "chat" {
|
||||
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
|
||||
}
|
||||
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("expected 1 top-level, got %d", len(cmds))
|
||||
}
|
||||
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
|
||||
list := findChild(cmds[0], "list")
|
||||
if list == nil {
|
||||
t.Fatal("list leaf not found")
|
||||
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds := BuildDynamicCommands(servers, runner, nil, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,16 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
Response: resp,
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
// Shape the MCP content payload (the actual data), not the
|
||||
// {endpoint, content} runtime envelope, so rename/drop/table
|
||||
// paths resolve against response fields (e.g. result.items)
|
||||
// rather than the wrapper. Falls back to the whole Response
|
||||
// when no content map is present (degraded/echo paths).
|
||||
if content, ok := result.Response["content"].(map[string]any); ok {
|
||||
result.Response["content"] = route.OutputTransform(content)
|
||||
} else {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
}
|
||||
@@ -742,7 +751,7 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
|
||||
"json": true, "params": true, "help": true,
|
||||
"format": true, "fields": true, "jq": true,
|
||||
"debug": true, "verbose": true, "dry-run": true,
|
||||
"yes": true, "mock": true, "timeout": true,
|
||||
"yes": true, "mock": true, "profile": true, "timeout": true,
|
||||
"client-id": true, "client-secret": true,
|
||||
}
|
||||
|
||||
@@ -862,7 +871,7 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
|
||||
if _, ok := existing[binding.Property]; ok {
|
||||
continue
|
||||
}
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
// Must not panic; the command must build and stay executable.
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
|
||||
boom, _, err := cmds[0].Find([]string{"boom"})
|
||||
if err != nil {
|
||||
t.Fatalf("find boom: %v", err)
|
||||
|
||||
@@ -288,6 +288,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
|
||||
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", "", "")
|
||||
|
||||
@@ -296,6 +297,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
|
||||
_ = 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)
|
||||
@@ -304,7 +306,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
|
||||
t.Errorf("name = %v, want Bob", params["name"])
|
||||
}
|
||||
// Global flags should be skipped
|
||||
for _, skip := range []string{"debug", "verbose", "dry_run", "format"} {
|
||||
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
|
||||
if _, exists := params[skip]; exists {
|
||||
t.Errorf("%s should be skipped (global flag)", skip)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
// 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, string_to_int64.
|
||||
// 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 "":
|
||||
@@ -49,6 +49,10 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
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:
|
||||
@@ -79,6 +83,77 @@ func transformInvertBool(value any) (any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -168,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
|
||||
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
|
||||
@@ -182,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
|
||||
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` to read from a file",
|
||||
"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
|
||||
@@ -199,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
|
||||
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\"}]') or use `json_parse` for YAML-tolerant parsing",
|
||||
"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
|
||||
|
||||
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -94,7 +94,7 @@ func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescripto
|
||||
|
||||
response, err := s.MarketClient.FetchServers(ctx, 200)
|
||||
if err == nil {
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL)
|
||||
_ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// newTestMCPServer returns an httptest.Server that handles both market registry
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
|
||||
// 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/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !marketOK {
|
||||
http.Error(w, "market unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+21
-19
@@ -21,6 +21,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -309,7 +311,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
}
|
||||
payload := map[string]any{"error": errorPayload}
|
||||
|
||||
data, marshalErr := json.MarshalIndent(payload, "", " ")
|
||||
data, marshalErr := jsonutil.MarshalIndent(payload, "", " ")
|
||||
if marshalErr != nil {
|
||||
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
||||
return writeErr
|
||||
@@ -344,25 +346,25 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
_, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
_, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error()))
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Line 1: Error summary
|
||||
lines := []string{
|
||||
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
|
||||
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
|
||||
}
|
||||
|
||||
// Always shown: hint, actions, retryable
|
||||
if typed.Hint != "" {
|
||||
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
|
||||
lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint)))
|
||||
}
|
||||
|
||||
// Add user-friendly hint for specific server error codes
|
||||
switch typed.ServerDiag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
|
||||
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
|
||||
lines = append(lines, tui.Cyan("Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"))
|
||||
lines = append(lines, tui.White("Action: 开启地址: "+config.GetDeveloperSettingsURL()))
|
||||
}
|
||||
|
||||
if len(typed.Actions) > 0 {
|
||||
@@ -370,53 +372,53 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if strings.TrimSpace(action) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action)))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
lines = append(lines, tui.Dim(line))
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
lines = append(lines, tui.Warning("Retryable: true"))
|
||||
}
|
||||
|
||||
// Always shown when present: Trace ID, Server Code
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)))
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)))
|
||||
}
|
||||
|
||||
// Verbose+: technical detail, snapshot, reason, server key
|
||||
if v >= VerbosityVerbose {
|
||||
if typed.ServerDiag.TechnicalDetail != "" {
|
||||
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)))
|
||||
}
|
||||
if typed.Reason != "" {
|
||||
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason)))
|
||||
}
|
||||
if typed.ServerKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
|
||||
}
|
||||
if typed.Cause != nil {
|
||||
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error())))
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: all internal diagnostics
|
||||
if v >= VerbosityDebug {
|
||||
if typed.Operation != "" {
|
||||
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation)))
|
||||
}
|
||||
if typed.RPCCode != 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode)))
|
||||
}
|
||||
if len(typed.RPCData) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-12
@@ -21,6 +21,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// hostControlProvider returns the host-owned clawType for the current
|
||||
@@ -105,12 +107,10 @@ const ExitCodePermission = 4
|
||||
// json.Unmarshal-able payload of the form
|
||||
// {"success":false,"code":<frozen enum>,"data":{...}}.
|
||||
//
|
||||
// When the payload includes data.uri, that URL is the authoritative
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
// it verbatim instead of parsing and reconstructing it locally, because
|
||||
// required parameters may live in query, encoded hash, or fragment sections.
|
||||
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
|
||||
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
|
||||
// When the payload includes data.uri/authUrl/authorizationUrl, that value is
|
||||
// the authoritative server-provided authorization link. The CLI accepts all
|
||||
// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a
|
||||
// single data.uri field so terminals and hosts do not need to deduplicate links.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -147,6 +147,7 @@ var patNoPermissionCodes = map[string]bool{
|
||||
// `dws auth login --scope <data.missingScope>`.
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"PAT_BATCH_AUTH_PENDING": true,
|
||||
"PAT_SCOPE_AUTH_REQUIRED": true,
|
||||
}
|
||||
|
||||
@@ -241,7 +242,7 @@ func isBusinessError(body map[string]any) bool {
|
||||
// Check order: DWS gateway auth > PAT permission.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
raw, _ := jsonutil.Marshal(content)
|
||||
return NewAuth(string(raw),
|
||||
WithReason("gateway_auth_expired"),
|
||||
WithHint(authExpiredHint()),
|
||||
@@ -353,8 +354,11 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
@@ -363,6 +367,16 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
func patAuthorizationURIFromData(data map[string]any) string {
|
||||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||||
value, _ := data[key].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
||||
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
||||
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
||||
@@ -443,9 +457,9 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
"code": code,
|
||||
}
|
||||
if data, ok := body["data"]; ok {
|
||||
// Keep data.uri exactly as returned by the service. Host consumers open
|
||||
// that link directly, so local normalization would risk dropping
|
||||
// parameters embedded in query/hash/fragment sections.
|
||||
// ApplyHostMutations canonicalizes PAT URL aliases into one data.uri
|
||||
// before JSON encoding, while stripClassFields keeps the rest of the
|
||||
// service payload intact.
|
||||
out["data"] = stripClassFields(data)
|
||||
} else {
|
||||
fallback := map[string]any{}
|
||||
|
||||
@@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") {
|
||||
t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "flow-1") {
|
||||
t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
@@ -729,6 +748,12 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
|
||||
@@ -745,8 +770,40 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted when data.uri is present")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted when data.uri is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_BATCH_AUTH_PENDING",
|
||||
"data": map[string]any{
|
||||
"flowId": "50dff7654b7444e88ced7489b07cce8d",
|
||||
"authUrl": rawURI,
|
||||
},
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING")
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,7 +859,7 @@ func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
@@ -817,17 +874,26 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=CFTC-27ZN") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
if got, _ := data["uri"].(string); got != want {
|
||||
t.Fatalf("data.uri = %q, want normalized %q", got, want)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != want {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
@@ -110,6 +110,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableFieldCreateCommand(runner),
|
||||
newAitableFieldUpdateCommand(runner),
|
||||
newAitableFieldDeleteCommand(runner),
|
||||
newAitableFieldSearchOptionsCommand(runner),
|
||||
newAitableFieldListAlias(runner),
|
||||
)
|
||||
|
||||
@@ -129,7 +130,13 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableRecordCreateCommand(runner),
|
||||
newAitableRecordUpdateCommand(runner),
|
||||
newAitableRecordBatchUpdateCommand(runner),
|
||||
newAitableRecordQueryEmptyCommand(runner),
|
||||
newAitableRecordDeleteCommand(runner),
|
||||
newAitableRecordHistoryListCommand(runner),
|
||||
newAitableRecordShareURLCommand(runner),
|
||||
newAitableRecordUpsertCommand(runner),
|
||||
newAitableRecordPrimaryDocGetCommand(runner),
|
||||
newAitableRecordPrimaryDocCreateCommand(runner),
|
||||
newAitableRecordListAlias(runner),
|
||||
)
|
||||
|
||||
@@ -251,6 +258,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newAitableDashboardUpdateCommand(runner),
|
||||
newAitableDashboardDeleteCommand(runner),
|
||||
newAitableDashboardConfigExampleCommand(runner),
|
||||
newAitableDashboardArrangeCommand(runner),
|
||||
dashboardShare,
|
||||
)
|
||||
|
||||
@@ -264,15 +272,53 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
viewGet := newAitableViewGetCommand(runner)
|
||||
viewGet.AddCommand(
|
||||
newAitableViewGetCardCommand(runner),
|
||||
newAitableViewGetTimebarCommand(runner),
|
||||
newAitableViewGetAggregateCommand(runner),
|
||||
newAitableViewGetFilterCommand(runner),
|
||||
newAitableViewGetSortCommand(runner),
|
||||
newAitableViewGetGroupCommand(runner),
|
||||
newAitableViewGetVisibleFieldsCommand(runner),
|
||||
newAitableViewGetFieldWidthsCommand(runner),
|
||||
newAitableViewGetLockCommand(runner),
|
||||
newAitableViewGetFrozenColsCommand(runner),
|
||||
newAitableViewGetRowHeightCommand(runner),
|
||||
newAitableViewGetFillColorRuleCommand(runner),
|
||||
)
|
||||
viewUpdate := newAitableViewUpdateCommand(runner)
|
||||
viewUpdate.AddCommand(
|
||||
newAitableViewUpdateCardCommand(runner),
|
||||
newAitableViewUpdateTimebarCommand(runner),
|
||||
newAitableViewUpdateAggregateCommand(runner),
|
||||
newAitableViewUpdateFieldWidthsCommand(runner),
|
||||
newAitableViewUpdateVisibleFieldsCommand(runner),
|
||||
newAitableViewUpdateFilterCommand(runner),
|
||||
newAitableViewUpdateSortCommand(runner),
|
||||
newAitableViewUpdateGroupCommand(runner),
|
||||
newAitableViewUpdateNameCommand(runner),
|
||||
newAitableViewUpdateFrozenColsCommand(runner),
|
||||
newAitableViewUpdateRowHeightCommand(runner),
|
||||
newAitableViewUpdateFillColorRuleCommand(runner),
|
||||
)
|
||||
view.AddCommand(
|
||||
newAitableViewGetCommand(runner),
|
||||
viewGet,
|
||||
newAitableViewListCommand(runner),
|
||||
newAitableViewCreateCommand(runner),
|
||||
newAitableViewUpdateCommand(runner),
|
||||
viewUpdate,
|
||||
newAitableViewDeleteCommand(runner),
|
||||
newAitableViewLockCommand(runner),
|
||||
newAitableViewDuplicateCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
|
||||
root.AddCommand(
|
||||
base, table, field, record, newAitableFormCommand(runner),
|
||||
newAitableWorkflowCommand(runner),
|
||||
template, attachment, export, importCmd, dashboard, chart, view,
|
||||
newAitableAdvpermCommand(runner),
|
||||
newAitableSectionCommand(runner),
|
||||
)
|
||||
|
||||
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
|
||||
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
|
||||
|
||||
@@ -130,7 +130,33 @@ func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
if folderID := aitableStringFlag(cmd, "folder-id"); folderID != "" {
|
||||
params["folderId"] = folderID
|
||||
}
|
||||
return runAitableTool(cmd, runner, "create_base", params)
|
||||
// dry-run 或带模板:保持单步 create_base 语义。
|
||||
if commandDryRun(cmd) {
|
||||
return runAitableTool(cmd, runner, "create_base", params)
|
||||
}
|
||||
result, err := runAitableProductToolResult(cmd, runner, "aitable", "create_base", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 默认表由服务端按 scenario 决定:wukong 场景建、openClaw 不建。
|
||||
// 为与 wukong 行为对齐,无模板时在 CLI 侧兜底补建一张默认表,
|
||||
// 使新 base 立即可用(含 tableId)。输出仍保持原 create_base 结果,
|
||||
// 不把内部补建步骤暴露给调用方。
|
||||
if _, hasTemplate := params["templateId"]; !hasTemplate {
|
||||
if baseID := findStringDeep(result.Response, "baseId", "baseID"); baseID != "" {
|
||||
if _, terr := runAitableProductToolResult(cmd, runner, "aitable", "create_table", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableName": "表格1",
|
||||
"fields": []any{map[string]any{
|
||||
"fieldName": "标题",
|
||||
"type": "text",
|
||||
}},
|
||||
}); terr != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: created base %s but default table creation failed: %v\n", baseID, terr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
@@ -1440,6 +1466,9 @@ func newAitableViewUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeAitableViewConfigBlock(cmd, config); err != nil {
|
||||
return err
|
||||
}
|
||||
params["config"] = config
|
||||
}
|
||||
if _, hasName := params["newViewName"]; !hasName {
|
||||
@@ -1499,6 +1528,14 @@ func runAitableFormTool(cmd *cobra.Command, runner executor.Runner, tool string,
|
||||
}
|
||||
|
||||
func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
|
||||
result, err := runAitableProductToolResult(cmd, runner, product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func runAitableProductToolResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
product,
|
||||
@@ -1509,9 +1546,9 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
if invocation.DryRun {
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
return executor.Result{}, err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
@@ -1523,7 +1560,7 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
timer.Stop()
|
||||
return cmd.Context().Err()
|
||||
return executor.Result{}, cmd.Context().Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
@@ -1531,16 +1568,16 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
lastErr = err
|
||||
if err == nil {
|
||||
return writeCommandPayload(cmd, result)
|
||||
return result, nil
|
||||
}
|
||||
if !aitableErrorRetryable(err) {
|
||||
return err
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
return executor.Result{}, lastErr
|
||||
}
|
||||
return nil
|
||||
return executor.Result{}, nil
|
||||
}
|
||||
|
||||
const aitableHelperMaxRetries = 3
|
||||
@@ -1906,11 +1943,15 @@ func normalizeAitableSort(items []any) []any {
|
||||
}
|
||||
|
||||
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
|
||||
var value []any
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
|
||||
}
|
||||
return value, nil
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON array / 数组, got %T", flagName, value))
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
|
||||
|
||||
@@ -1393,3 +1393,55 @@ func singleAitableRecord(t *testing.T, value any) map[string]any {
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
|
||||
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
|
||||
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"data": map[string]any{"baseId": "BASE_001"}},
|
||||
{"data": map[string]any{"tableId": "TABLE_001"}},
|
||||
}}
|
||||
cmd := newAitableBaseCreateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--name", "项目跟踪"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].Tool; got != "create_base" {
|
||||
t.Fatalf("first tool = %q, want create_base", got)
|
||||
}
|
||||
if got := runner.calls[1].Tool; got != "create_table" {
|
||||
t.Fatalf("second tool = %q, want create_table", got)
|
||||
}
|
||||
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
|
||||
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
// base create 带 --template-id 时不补建默认表(模板自带结构)。
|
||||
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"data": map[string]any{"baseId": "BASE_001"}},
|
||||
}}
|
||||
cmd := newAitableBaseCreateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].Tool; got != "create_base" {
|
||||
t.Fatalf("tool = %q, want create_base", got)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
type aitableSequencedRunner struct {
|
||||
calls []executor.Invocation
|
||||
responses []map[string]any
|
||||
}
|
||||
|
||||
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls = append(r.calls, invocation)
|
||||
var response map[string]any
|
||||
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
|
||||
response = r.responses[idx]
|
||||
}
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableFieldSearchOptionsCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--field-id", "FIELD_001",
|
||||
"--keyword", "已",
|
||||
"--limit", "10",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "search_field_options" {
|
||||
t.Fatalf("Tool = %q, want search_field_options", got)
|
||||
}
|
||||
if got := runner.last.Params["keyword"]; got != "已" {
|
||||
t.Fatalf("keyword = %#v, want 已", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 10 {
|
||||
t.Fatalf("limit = %#v, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{{
|
||||
"content": map[string]any{
|
||||
"status": "success",
|
||||
"data": map[string]any{"views": []any{map[string]any{
|
||||
"viewId": "VIEW_001",
|
||||
"viewType": "Grid",
|
||||
"custom": map[string]any{
|
||||
"widthMap": map[string]any{"FIELD_001": 240},
|
||||
},
|
||||
}}},
|
||||
},
|
||||
}}}
|
||||
cmd := newAitableViewGetFieldWidthsCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
|
||||
}
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", payload["data"])
|
||||
}
|
||||
if got := data["FIELD_001"]; got != float64(240) {
|
||||
t.Fatalf("FIELD_001 width = %#v, want 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableSequencedRunner{responses: []map[string]any{
|
||||
{"content": map[string]any{
|
||||
"status": "success",
|
||||
"data": map[string]any{"views": []any{map[string]any{
|
||||
"viewId": "VIEW_001",
|
||||
"viewType": "Gallery",
|
||||
}}},
|
||||
}},
|
||||
{"content": map[string]any{"status": "success"}},
|
||||
}}
|
||||
cmd := newAitableViewUpdateCardCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--cover-mode", "custom",
|
||||
"--cover-field-id", "FIELD_001",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want 2", len(runner.calls))
|
||||
}
|
||||
update := runner.calls[1]
|
||||
if update.Tool != "update_view" {
|
||||
t.Fatalf("second tool = %q, want update_view", update.Tool)
|
||||
}
|
||||
config, ok := update.Params["config"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("config = %#v, want object", update.Params["config"])
|
||||
}
|
||||
card, ok := config["galleryCard"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
|
||||
}
|
||||
if got := card["coverMode"]; got != "custom" {
|
||||
t.Fatalf("coverMode = %#v, want custom", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewUpdateCommand(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--config", `{"frozenColCount":2}`,
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "frozen-cols") {
|
||||
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordHistoryListCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--record-id", "REC_001",
|
||||
"--offset", "10",
|
||||
"--limit", "30",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "query_record_history" {
|
||||
t.Fatalf("Tool = %q, want query_record_history", got)
|
||||
}
|
||||
if got := runner.last.Params["recordId"]; got != "REC_001" {
|
||||
t.Fatalf("recordId = %#v, want REC_001", got)
|
||||
}
|
||||
if got := runner.last.Params["offset"]; got != 10 {
|
||||
t.Fatalf("offset = %#v, want 10", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 30 {
|
||||
t.Fatalf("limit = %#v, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordUpsertCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "record_upsert" {
|
||||
t.Fatalf("Tool = %q, want record_upsert", got)
|
||||
}
|
||||
records, ok := runner.last.Params["records"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("records len = %d, want 2", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lock unlock", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewLockCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--off",
|
||||
)
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "lock_or_unlock_view" {
|
||||
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
|
||||
}
|
||||
if got := runner.last.Params["action"]; got != "unlock" {
|
||||
t.Fatalf("action = %#v, want unlock", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fill color rule", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--view-id", "VIEW_001",
|
||||
"--json", `[]`,
|
||||
)
|
||||
if got := runner.last.CanonicalProduct; got != "aitable" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
|
||||
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
|
||||
}
|
||||
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
|
||||
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableWorkflowListCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--limit", "50",
|
||||
"--offset", "100",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "list_workflows" {
|
||||
t.Fatalf("Tool = %q, want list_workflows", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 50 {
|
||||
t.Fatalf("limit = %#v, want 50", got)
|
||||
}
|
||||
if got := runner.last.Params["offset"]; got != 100 {
|
||||
t.Fatalf("offset = %#v, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordQueryEmptyCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--limit", "50",
|
||||
"--cursor", "CUR_001",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "query_empty_records" {
|
||||
t.Fatalf("Tool = %q, want query_empty_records", got)
|
||||
}
|
||||
if got := runner.last.Params["limit"]; got != 50 {
|
||||
t.Fatalf("limit = %#v, want 50", got)
|
||||
}
|
||||
if got := runner.last.Params["cursor"]; got != "CUR_001" {
|
||||
t.Fatalf("cursor = %#v, want CUR_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableRecordQueryEmptyCommand(runner)
|
||||
cmd.SetArgs([]string{
|
||||
"--base-id", "BASE_001",
|
||||
"--table-id", "TABLE_001",
|
||||
"--limit", "200",
|
||||
})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for --limit 200, got nil")
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableDashboardArrangeCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--dashboard-id", "DASH_001",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "align_dashboard" {
|
||||
t.Fatalf("Tool = %q, want align_dashboard", got)
|
||||
}
|
||||
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
|
||||
t.Fatalf("dashboardId = %#v, want DASH_001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableAdvpermRoleCreateCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--name", "市场可读",
|
||||
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "create_role" {
|
||||
t.Fatalf("Tool = %q, want create_role", got)
|
||||
}
|
||||
subRoles, ok := runner.last.Params["subRoles"].([]any)
|
||||
if !ok || len(subRoles) != 1 {
|
||||
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aitableCommandRunner{}
|
||||
cmd := newAitableSectionMoveNodeCommand(runner)
|
||||
executeAitableExtraCommand(t, cmd,
|
||||
"--base-id", "BASE_001",
|
||||
"--node-id", "NODE_001",
|
||||
"--new-parent-section-id", "",
|
||||
"--target-index", "0",
|
||||
)
|
||||
|
||||
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
||||
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
||||
}
|
||||
if got := runner.last.Tool; got != "move_nsheet_node" {
|
||||
t.Fatalf("Tool = %q, want move_nsheet_node", got)
|
||||
}
|
||||
if got := runner.last.Params["newParentSectionId"]; got != "" {
|
||||
t.Fatalf("newParentSectionId = %#v, want empty string", got)
|
||||
}
|
||||
if got := runner.last.Params["targetIndex"]; got != 0 {
|
||||
t.Fatalf("targetIndex = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"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/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler { return calendarHandler{} })
|
||||
}
|
||||
|
||||
// calendarHandler contributes the `calendar attendee list|add|delete` group.
|
||||
// wukong renamed the calendar participant commands to "attendee" (former name:
|
||||
// participant); the envelope still exposes them under "participant". These
|
||||
// leaves call the same MCP tools (get/add/remove_calendar_participant) so the
|
||||
// wukong command surface is aligned without dropping the legacy participant
|
||||
// path. MergeCommandTree folds the attendee group into the calendar tree.
|
||||
type calendarHandler struct{}
|
||||
|
||||
func (calendarHandler) Name() string { return "calendar" }
|
||||
|
||||
func (calendarHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "calendar",
|
||||
Short: i18n.T("日历"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
attendee := &cobra.Command{
|
||||
Use: "attendee",
|
||||
Short: i18n.T("参会人管理(与 participant 等价,对齐 wukong 命名)"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
attendee.AddCommand(
|
||||
newCalendarAttendeeListCommand(runner),
|
||||
newCalendarAttendeeAddCommand(runner),
|
||||
newCalendarAttendeeDeleteCommand(runner),
|
||||
)
|
||||
root.AddCommand(attendee)
|
||||
return root
|
||||
}
|
||||
|
||||
func newCalendarAttendeeListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list", Short: i18n.T("查询日程参会人"),
|
||||
Example: " dws calendar attendee list --event <eventId>", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "get_calendar_participants", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选, 默认主日历)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarAttendeeAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add", Short: i18n.T("添加日程参会人"),
|
||||
Example: " dws calendar attendee add --event <eventId> --users userId1,userId2", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
if users == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --users")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID, "attendeesToAdd": csvToList(users)}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "optional")); v != "" {
|
||||
params["optional"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "add_calendar_participant", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
|
||||
cmd.Flags().String("optional", "", i18n.T("是否可选参会人 (可选)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarAttendeeDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete", Short: i18n.T("移除日程参会人"),
|
||||
Example: " dws calendar attendee delete --event <eventId> --users userId1", Args: cobra.NoArgs, DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
|
||||
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --event")
|
||||
}
|
||||
if users == "" {
|
||||
return apperrors.NewValidation("missing required flag(s): --users")
|
||||
}
|
||||
params := map[string]any{"eventId": eventID, "attendeesToRemove": csvToList(users)}
|
||||
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
|
||||
params["calendarId"] = v
|
||||
}
|
||||
return runCalendarTool(cmd, runner, "remove_calendar_participant", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
|
||||
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
|
||||
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCalendarTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "calendar", tool, params)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, inv)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func csvToList(s string) []any {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newChatBotSearchCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(message, group, bot)
|
||||
root.AddCommand(message, group, bot, newChatFileGroup(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -334,13 +334,14 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
|
||||
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
|
||||
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
|
||||
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
|
||||
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
|
||||
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
|
||||
// attachAITag 在 --ai-tag 为真时给发送参数加上 clawType,由 IM 服务端据此
|
||||
// 渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。--ai-tag 默认 true:
|
||||
// 经 dws/agent 代发的消息默认带角标以透明标识 AI/CLI 代发,仅当用户显式
|
||||
// 传 --ai-tag=false 时才不带 (按本人发送)。
|
||||
func attachAITag(cmd *cobra.Command, params map[string]any) {
|
||||
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
|
||||
params["clawType"] = edition.ClawType()
|
||||
@@ -1017,7 +1018,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
|
||||
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
|
||||
// multi-step upload to the conversation file space: for --file it does
|
||||
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
|
||||
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
|
||||
// cannot express the local pipeline, so it lives here. Wired into the chat
|
||||
// handler (see chat.go).
|
||||
func newChatFileGroup(runner executor.Runner) *cobra.Command {
|
||||
file := &cobra.Command{
|
||||
Use: "file",
|
||||
Short: "会话文件上传",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
|
||||
}
|
||||
file.AddCommand(newChatFileUploadCommand(runner))
|
||||
return file
|
||||
}
|
||||
|
||||
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传本地文件或 URL 文件到会话文件空间",
|
||||
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
|
||||
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := chatConversationTargetArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
|
||||
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
|
||||
if filePath == "" && fileURL == "" {
|
||||
return apperrors.NewValidation("--file or --url is required")
|
||||
}
|
||||
if filePath != "" && fileURL != "" {
|
||||
return apperrors.NewValidation("--file and --url are mutually exclusive")
|
||||
}
|
||||
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
|
||||
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
|
||||
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
|
||||
|
||||
// URL path: server pulls the file (chat server).
|
||||
if fileURL != "" {
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(fileURL)
|
||||
}
|
||||
params := cloneStringAnyMap(target)
|
||||
params["fileUrl"] = fileURL
|
||||
params["fileName"] = fileName
|
||||
if md5v != "" {
|
||||
params["md5"] = md5v
|
||||
}
|
||||
if uuid != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, inv)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
// Local path: init (im) -> HTTP PUT -> commit (im).
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return apperrors.NewValidation(filePath + " is a directory, not a file")
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
if md5v == "" {
|
||||
if md5v, err = fileMD5Hex(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if commandDryRun(cmd) {
|
||||
preview := cloneStringAnyMap(target)
|
||||
preview["fileName"] = fileName
|
||||
preview["fileSize"] = fileSize
|
||||
preview["md5"] = md5v
|
||||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
initParams := cloneStringAnyMap(target)
|
||||
initParams["fileName"] = fileName
|
||||
initParams["fileSize"] = fileSize
|
||||
initParams["md5"] = md5v
|
||||
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
|
||||
if resourceURL == "" {
|
||||
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
|
||||
}
|
||||
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
|
||||
if resourceURL == "" || uploadKey == "" {
|
||||
return apperrors.NewAPI("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
|
||||
}
|
||||
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
|
||||
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitParams := cloneStringAnyMap(target)
|
||||
commitParams["uploadKey"] = uploadKey
|
||||
commitParams["fileName"] = fileName
|
||||
commitParams["fileSize"] = fileSize
|
||||
commitParams["md5"] = md5v
|
||||
if uuid != "" {
|
||||
commitParams["uuid"] = uuid
|
||||
}
|
||||
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, commitRes)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
|
||||
cmd.Flags().String("conversation-id", "", "--group 的别名")
|
||||
cmd.Flags().String("id", "", "--group 的别名")
|
||||
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
|
||||
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
|
||||
cmd.Flags().String("file-path", "", "--file 的别名")
|
||||
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
|
||||
cmd.Flags().String("file-name", "", "文件名(可选)")
|
||||
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
|
||||
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
|
||||
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
|
||||
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
|
||||
if group == "" && user == "" && openDingTalkID == "" {
|
||||
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
|
||||
}
|
||||
m := map[string]any{}
|
||||
if group != "" {
|
||||
m["openConversationId"] = group
|
||||
}
|
||||
if user != "" {
|
||||
m["userId"] = user
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
m["openDingTalkId"] = openDingTalkID
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in)+4)
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user