Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7b490e08 | ||
|
|
e2ab422787 | ||
|
|
4d05ea4fc1 | ||
|
|
e3bbb33c18 | ||
|
|
1c09115bd6 | ||
|
|
a0a4b5dfbe | ||
|
|
3f653d9da0 | ||
|
|
6e0917a3ed | ||
|
|
b5f431ba51 | ||
|
|
950de23e74 | ||
|
|
0108b1ca28 | ||
|
|
34aad4596c | ||
|
|
07d2e4597e | ||
|
|
57d753cd1d | ||
|
|
9e12da0219 | ||
|
|
7ca9ebeb57 | ||
|
|
d202d58963 | ||
|
|
4068847742 | ||
|
|
536fd66029 | ||
|
|
a519a2ff8a | ||
|
|
270771de0c | ||
|
|
3ec8320680 | ||
|
|
2c4d539a02 | ||
|
|
59abfc7dfd | ||
|
|
a676f3d606 | ||
|
|
c79c5dfffe | ||
|
|
681f2db87a | ||
|
|
7dc5b6f2ed | ||
|
|
f9b37486fd | ||
|
|
ad6e22d8cf | ||
|
|
05c0f1af1e | ||
|
|
605d9360fc | ||
|
|
fb4e6a0fdb | ||
|
|
2b715e35ad | ||
|
|
f56de38b79 | ||
|
|
c2e5fec967 | ||
|
|
f0642c73d7 | ||
|
|
c1bbc183ad | ||
|
|
579cd86c6c | ||
|
|
b6c85f31c4 | ||
|
|
88d82f201f | ||
|
|
b6df97fba1 | ||
|
|
a9ee1cd24d | ||
|
|
2614d225f2 | ||
|
|
e4faa0daa8 | ||
|
|
975f378559 | ||
|
|
28e83dfa57 | ||
|
|
7f07c22cd5 | ||
|
|
089a661124 | ||
|
|
d2f7c667e2 | ||
|
|
ff4961ebbe | ||
|
|
68d3c76d2d | ||
|
|
3811a0d82e | ||
|
|
e00019039c | ||
|
|
bc332133a2 | ||
|
|
3fdf06fb51 | ||
|
|
ede677e413 | ||
|
|
b5abe6d328 | ||
|
|
51f531c3fd | ||
|
|
8d21510aec | ||
|
|
1a51f3a8da | ||
|
|
4d284c3740 | ||
|
|
fe5f484c29 | ||
|
|
8f4ab176a8 | ||
|
|
d288820b64 | ||
|
|
22d19863fb | ||
|
|
c02df07b64 | ||
|
|
e615bd433c | ||
|
|
e26e96eadc | ||
|
|
5b746f610a | ||
|
|
74fa24ee1e | ||
|
|
474ce88d47 | ||
|
|
6a0cdbc323 | ||
|
|
b35d67b811 | ||
|
|
397654890e | ||
|
|
a945663674 | ||
|
|
a14525ed1d | ||
|
|
816c356bbf | ||
|
|
f28dd6ee07 | ||
|
|
765338eb5b | ||
|
|
0201340b28 | ||
|
|
0bd767a3fe | ||
|
|
e75df36dfc | ||
|
|
648d604757 | ||
|
|
42627e769e | ||
|
|
3b22bb4994 | ||
|
|
d78de010ff | ||
|
|
6c192c2bf4 | ||
|
|
be5ce782b6 | ||
|
|
de35b08c8c | ||
|
|
e1a50f08a6 | ||
|
|
d14ce3c8d2 | ||
|
|
e0dc26c8c7 | ||
|
|
adc87a92e4 | ||
|
|
b876b9b4ae | ||
|
|
82c6bcfcbf | ||
|
|
48a79b17c6 | ||
|
|
d23910ce35 | ||
|
|
084188c7ac | ||
|
|
9b44eeb5b0 | ||
|
|
1822b82232 | ||
|
|
44403e4d23 | ||
|
|
ec29dc0e22 | ||
|
|
9f0966c05a | ||
|
|
bf105d3d29 | ||
|
|
6de77f4c34 | ||
|
|
00f1379874 | ||
|
|
48681eb41c | ||
|
|
5e41b8a6e8 | ||
|
|
8687d68567 | ||
|
|
bf74159737 | ||
|
|
296e9a73f0 | ||
|
|
3929719b51 | ||
|
|
3ba0b90f9e | ||
|
|
c2a6ce01aa | ||
|
|
09a300867c | ||
|
|
73e010a992 | ||
|
|
d8ffea02ab | ||
|
|
809d026b7e | ||
|
|
87ac7048bd | ||
|
|
fafb6f47b9 | ||
|
|
8633246eff | ||
|
|
3e9e76df2c | ||
|
|
5fde6222d4 | ||
|
|
fde008a25d | ||
|
|
eb5569ca21 | ||
|
|
b7c14c118f | ||
|
|
fb4cf70c93 | ||
|
|
890dfea477 | ||
|
|
b9f2733821 | ||
|
|
83543b20df | ||
|
|
4e4705c673 | ||
|
|
cd652bf9ab | ||
|
|
c5463424be | ||
|
|
00bb595768 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
paths:
|
||||
.github/workflows/release.yml:
|
||||
ignore:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
@@ -1 +1 @@
|
||||
<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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 100.0%"><title>coverage: 100.0%</title><filter id="blur"><feGaussianBlur 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="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4b0"/><rect width="114" 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"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">100.0%</text><text x="865" y="150" fill-opacity=".3" textLength="430">100.0%</text></g><text x="865" y="140" textLength="430">100.0%</text></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,95 @@
|
||||
name: AI Behavior Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior Policy Evaluator
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Deliberately do not check out or execute pull-request code here.
|
||||
# pull_request_target keeps this policy anchored to the base branch.
|
||||
- name: Check AI-generated PR boundaries
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior Check',
|
||||
description,
|
||||
});
|
||||
|
||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||
|
||||
const labels = context.payload.pull_request.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
+551
-48
@@ -7,8 +7,11 @@ on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -26,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Format Check
|
||||
run: |
|
||||
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||
|
||||
- name: Go Vet
|
||||
@@ -39,10 +42,34 @@ jobs:
|
||||
# version: v1.64.8
|
||||
# args: ./...
|
||||
|
||||
test:
|
||||
name: Test
|
||||
actionlint:
|
||||
name: Actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check GitHub Actions workflows
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -56,19 +83,85 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' }}
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
# The registry-first final-delivery gate validates all public commands
|
||||
# and the complete generated Catalog under the race detector. Keep the
|
||||
# package timeout aligned with the macOS race job so the Linux runner's
|
||||
# five-minute default does not expire while that gate is still making
|
||||
# progress.
|
||||
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
|
||||
- name: Test shard with Race Detection
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
case "$TEST_SHARD" in
|
||||
app)
|
||||
packages=(./internal/app/...)
|
||||
;;
|
||||
generators)
|
||||
packages=(./internal/generator/...)
|
||||
;;
|
||||
helpers)
|
||||
packages=(./internal/helpers/...)
|
||||
;;
|
||||
remaining)
|
||||
mapfile -t packages < <(
|
||||
go list ./cmd/... ./internal/... |
|
||||
grep -Ev '/internal/(app|generator|helpers)(/|$)'
|
||||
)
|
||||
;;
|
||||
*)
|
||||
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (release scripts)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs:
|
||||
- test-race
|
||||
- test-release-scripts
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
RACE_RESULT: ${{ needs.test-race.result }}
|
||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
@@ -101,18 +194,107 @@ jobs:
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
- name: Test Windows auth and DPAPI packages
|
||||
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
- name: Test Windows auth migration and portable auth diagnostics
|
||||
run: go test -v -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
|
||||
|
||||
coverage-darwin:
|
||||
name: Coverage (macOS)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce macOS changed-code coverage
|
||||
run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt
|
||||
|
||||
- name: Upload macOS coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-darwin
|
||||
path: coverage-darwin.txt
|
||||
|
||||
coverage-windows:
|
||||
name: Coverage (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
shell: bash
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce Windows changed-code coverage
|
||||
shell: bash
|
||||
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
|
||||
|
||||
- name: Upload Windows coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.txt
|
||||
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -125,11 +307,200 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run tests with coverage
|
||||
- name: Run current unit tests with coverage
|
||||
run: |
|
||||
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Upload current coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
|
||||
- name: Upload supporting coverage profiles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: |
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
run: |
|
||||
set -eu
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: coverage-base.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
- coverage-current
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Verify coverage profile jobs
|
||||
env:
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
"baseline:$BASELINE_RESULT"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
env:
|
||||
COVERAGE_TARGET: "80"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
@@ -139,32 +510,11 @@ jobs:
|
||||
name: coverage-report
|
||||
path: |
|
||||
coverage.txt
|
||||
coverage-base.txt
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
coverage.html
|
||||
|
||||
- name: Update coverage badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
mkdir -p .github/badges
|
||||
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
|
||||
|
||||
- name: Commit badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/badges/coverage.svg || true
|
||||
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
|
||||
git push || true
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -184,8 +534,98 @@ jobs:
|
||||
- name: Policy
|
||||
run: make policy
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
interface-integrity:
|
||||
name: Interface Integrity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
run: make skill-command-integrity
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
run: make cli-smoke
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check HTTP and stdio MCP transport
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
@@ -203,11 +643,74 @@ jobs:
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs:
|
||||
- lint
|
||||
- actionlint
|
||||
- test
|
||||
- test-darwin
|
||||
- test-windows
|
||||
- coverage
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
- policy
|
||||
- interface-integrity
|
||||
- cli-smoke
|
||||
- mock-mcp-smoke
|
||||
- edition-tests
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
ACTIONLINT_RESULT: ${{ needs.actionlint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Actionlint:$ACTIONLINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
|
||||
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
|
||||
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
|
||||
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
@@ -10,8 +11,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -23,13 +22,14 @@ concurrency:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
- name: Checkout main history
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -41,15 +41,7 @@ jobs:
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
@@ -81,7 +73,6 @@ jobs:
|
||||
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 本地化)
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Publish npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to publish to npm (e.g. v1.0.48)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+854
-154
File diff suppressed because it is too large.
Load diff
@@ -1,50 +0,0 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
@@ -19,6 +19,7 @@ test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
@@ -47,4 +48,9 @@ test/dev_functional/results.jsonl
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
# Local Go coverage output
|
||||
/coverage.txt
|
||||
/coverage-base.txt
|
||||
/coverage-policy.txt
|
||||
/coverage.html
|
||||
dwsbin
|
||||
+2
-7
@@ -1,19 +1,14 @@
|
||||
# GoReleaser configuration for dws
|
||||
# Docs: https://goreleaser.com
|
||||
#
|
||||
# To release:
|
||||
# git tag -a v0.1.0 -m "Release v0.1.0"
|
||||
# git push origin v0.1.0
|
||||
# To release, use scripts/release/release.sh. It seals main, validates the
|
||||
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
|
||||
#
|
||||
# To test locally (no publish):
|
||||
# goreleaser release --snapshot --clean
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd
|
||||
binary: dws
|
||||
|
||||
+23
-1
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.53-beta.2] - 2026-07-16
|
||||
|
||||
This beta validates the accumulated post-v1.0.52 command surface, release automation, and runtime hardening changes, including enterprise contact onboarding, declarative shortcuts, Sheet/Aitable writes, multi-platform Homebrew formulas, and credential and target-validation fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Contact enterprise onboarding commands** — adds `contact org create`, `contact user invite`, and `contact account create` for creating a DingTalk enterprise, inviting an employee by mobile, and provisioning an enterprise login account, with reviewed Schema contracts and mono/multi Skill routing.
|
||||
- **Declarative shortcut commands** (#592) — adds 366 `dws <service> +<command>` shortcuts across 16 services, including one-to-one MCP wrappers and multi-step smart workflows. Shortcuts publish stable Agent-visible contracts with named flags, validation and confirmation metadata, dry-run protection for writes, catalog/help routing, and optional local YAML extensions and usage recording.
|
||||
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
|
||||
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, Homebrew, and optional Gitee delivery with fail-closed recovery checks.
|
||||
- **Reviewed historical release recovery proofs** — release preflight can recognize an explicitly pinned successful recovery delivery for a historical stable tag while still rejecting arbitrary workflow dispatches, mismatched commits, and incomplete release, signing, or publication jobs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT organization-policy denials stop immediately** — `PAT_ORG_POLICY_DENIED` now remains terminal even if a backend also returns `flowId`, authorization URLs, or client credentials; the CLI does not mutate process credentials, open a browser, poll, or retry until an organization administrator changes the policy.
|
||||
- **Sheet and task invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while task completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
|
||||
- **Windows portable-auth contract** — `dws auth export` and `dws auth import` now fail early without reading credentials, bundles, or writing files instead of claiming portable-bundle support for DPAPI-protected HKCU Registry credentials.
|
||||
- **Concurrent credential writes and reentrant CLI execution** — secure-token writers now use isolated, exclusive temporary files before atomic replacement so concurrent processes cannot remove each other's in-flight data, and repeated in-process CLI runs close the previous file logger before replacing it instead of retaining the prior log-file handle.
|
||||
|
||||
## [1.0.52] - 2026-07-14
|
||||
|
||||
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
|
||||
@@ -108,7 +131,6 @@ This release promotes the sealed **remove-discovery delivery** from the beta lin
|
||||
|
||||
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
|
||||
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
|
||||
|
||||
## [1.0.47] - 2026-07-05
|
||||
|
||||
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ git diff --check
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.53-beta.1"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7fef5add684189bfef16a1731fab4883fa096115dcd645c79bf9b3918758dbcb"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "8cde0fd62849dcb52bc053022361f983953bd5a4bac1edfc96076a84636debbe"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "17989f94b422d6490249c5e095049a9efc1c3861176fc249d5e7eea283f5e675"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "439d6a1ffc9572ed461b77a8bb27f6ddb4d4c9ce9d58474c0180955fc8a1d8d7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.1/dws-skills.zip"
|
||||
sha256 "a93ba8a73f2e319038a7bcb32038b9e7bd93d1e9af012a430ebf994498c140d4"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.52"
|
||||
license "Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
|
||||
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
|
||||
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
|
||||
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
|
||||
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
|
||||
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,12 @@
|
||||
GO ?= go
|
||||
REMOTE ?=
|
||||
PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -10,13 +16,26 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and Schema registry checks\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -32,20 +51,51 @@ lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
fmt:
|
||||
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
|
||||
policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-schema-command-registry.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
@./scripts/policy/check-generated-drift.sh
|
||||
@./scripts/policy/check-schema-catalog.sh
|
||||
@./scripts/policy/check-schema-binary.sh
|
||||
@$(MAKE) test-schema-agent-examples
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
|
||||
coverage-gate-platform:
|
||||
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
|
||||
|
||||
update-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --update
|
||||
|
||||
reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
|
||||
@@ -85,8 +135,8 @@ generate-schema-catalog:
|
||||
-output internal/cli/schema_catalog.json
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
|
||||
|
||||
publish-homebrew-formula:
|
||||
@./scripts/release/publish-homebrew-formula.sh
|
||||
@@ -94,6 +144,32 @@ publish-homebrew-formula:
|
||||
setup-hooks:
|
||||
@git config core.hooksPath scripts/hooks 2>/dev/null || true
|
||||
|
||||
changelog-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
|
||||
|
||||
changelog-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
|
||||
|
||||
release-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release:
|
||||
goreleaser release --clean
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
|
||||
@exit 2
|
||||
@@ -93,6 +93,30 @@ How to pick:
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
Install the latest beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
|
||||
|
||||
Install the keg-only Homebrew beta without replacing the stable Formula:
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -168,6 +192,18 @@ dws upgrade -y # skip confirmation prompt
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
### Six-channel post-release verification
|
||||
|
||||
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
@@ -287,6 +323,9 @@ dws auth status # confirm "Refresh Token: valid"
|
||||
```
|
||||
|
||||
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
|
||||
Windows export and import are intentionally rejected before credentials or
|
||||
bundles are read: Windows stores credentials as DPAPI-protected HKCU Registry
|
||||
values, and the current file-DEK bundle has no safe DPAPI-to-portable conversion.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -619,7 +658,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
|
||||
| Contact | `contact` | Look up users, departments, labels, roster profiles and dismissals; create enterprises and enterprise accounts; invite employees |
|
||||
| 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 |
|
||||
|
||||
+37
-1
@@ -93,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
安装最新 beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew**(macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
|
||||
|
||||
安装 Homebrew beta(keg-only,不覆盖稳定版):
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -165,6 +189,18 @@ dws upgrade -y # 跳过确认直接升级
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
### 六渠道发布后验证
|
||||
|
||||
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
@@ -611,7 +647,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职;创建企业、企业账号及邀请员工 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "__DESCRIPTION__"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "__DARWIN_ARM64_URL__"
|
||||
sha256 "__DARWIN_ARM64_SHA256__"
|
||||
else
|
||||
url "__DARWIN_AMD64_URL__"
|
||||
sha256 "__DARWIN_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "__LINUX_ARM64_URL__"
|
||||
sha256 "__LINUX_ARM64_SHA256__"
|
||||
else
|
||||
url "__LINUX_AMD64_URL__"
|
||||
sha256 "__LINUX_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "__SKILLS_URL__"
|
||||
sha256 "__SKILLS_SHA256__"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
__CHANNEL_CAVEAT__
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
+7
-38
@@ -1,5 +1,5 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "DingTalk Workspace CLI"
|
||||
desc "Install locally built DingTalk workspace CLI artifacts for verification"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
@@ -12,8 +12,6 @@ __KEG_ONLY_LINE__
|
||||
end
|
||||
|
||||
def install
|
||||
require "fileutils"
|
||||
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
@@ -28,44 +26,15 @@ __KEG_ONLY_LINE__
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
FileUtils.cp_r(Dir["*"], skill_dest)
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def post_install
|
||||
require "fileutils"
|
||||
|
||||
skill_root = pkgshare/"skills/dws"
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
targets = [
|
||||
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")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
parent_gate = dest.parent.parent
|
||||
next if index > 0 && !parent_gate.directory?
|
||||
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.mkdir_p(dest)
|
||||
FileUtils.cp_r(entries, dest)
|
||||
end
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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.
|
||||
|
||||
// interface-snapshot is an internal CI helper. It is intentionally a separate
|
||||
// binary so it can be copied into a temporary worktree and compiled against an
|
||||
// older revision's real Cobra root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
if err := runGenerate(args[1:], stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
case "compare":
|
||||
compatible, err := runCompare(args[1:], stdout, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
if !compatible {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "-", "snapshot output path, or - for stdout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("generate accepts no positional arguments")
|
||||
}
|
||||
|
||||
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated home: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(home)
|
||||
|
||||
environment := map[string]string{
|
||||
"DWS_CONFIG_DIR": home,
|
||||
"DWS_LANG": "en",
|
||||
"HOME": home,
|
||||
"NO_COLOR": "1",
|
||||
"USERPROFILE": home,
|
||||
}
|
||||
type previousEnv struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
previous := make(map[string]previousEnv, len(environment))
|
||||
for key, value := range environment {
|
||||
oldValue, wasSet := os.LookupEnv(key)
|
||||
previous[key] = previousEnv{value: oldValue, set: wasSet}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for key, old := range previous {
|
||||
if old.set {
|
||||
_ = os.Setenv(key, old.value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
previousLang := i18n.Lang()
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(*output))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create snapshot %q: %w", *output, err)
|
||||
}
|
||||
writeErr := interfacesnapshot.Write(file, snapshot)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return false, fmt.Errorf("compare accepts no positional arguments")
|
||||
}
|
||||
if *currentPath == "" {
|
||||
return false, fmt.Errorf("compare requires --current")
|
||||
}
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read current snapshot: %w", err)
|
||||
}
|
||||
references := make(map[string]interfacesnapshot.Snapshot, 2)
|
||||
if *basePath != "" {
|
||||
references["main"], err = readSnapshot(*basePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if *stablePath != "" {
|
||||
references["stable"], err = readSnapshot(*stablePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read stable snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
return false, fmt.Errorf("write comparison report: %w", err)
|
||||
}
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.Snapshot{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.Read(file)
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode generated snapshot: %v", err)
|
||||
}
|
||||
|
||||
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
commands[command.Path] = command
|
||||
}
|
||||
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
|
||||
if _, ok := commands[path]; !ok {
|
||||
t.Errorf("actual root snapshot is missing %q", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"dws completion", "dws help"} {
|
||||
if _, ok := commands[path]; ok {
|
||||
t.Errorf("framework-noise path %q leaked into snapshot", path)
|
||||
}
|
||||
}
|
||||
|
||||
create := commands["dws dev app create"]
|
||||
if !hasFlag(create.LocalFlags, "name", "string") {
|
||||
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
|
||||
}
|
||||
if !hasFlag(create.InheritedFlags, "profile", "string") {
|
||||
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
stable := commandSnapshot("dws", "dws legacy")
|
||||
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", current)
|
||||
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", stable)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", mergeBasePath,
|
||||
"--stable", stablePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
|
||||
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
commands = append(commands, interfacesnapshot.Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if err := interfacesnapshot.Write(file, snapshot); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+3
-1
@@ -19,6 +19,8 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
|
||||
func main() {
|
||||
os.Exit(app.Execute())
|
||||
exit(app.Execute())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
called = true
|
||||
code = value
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,24 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
|
||||
allow fine-grained personal access tokens to target this repository, so use a
|
||||
classic personal access token owned by a maintainer or release-bot account with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
|
||||
secret. Replace it immediately if it is exposed, its owner loses repository
|
||||
access, or the release-bot ownership changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Pull request quality gates
|
||||
|
||||
The repository defines five focused checks in addition to its existing CI:
|
||||
|
||||
- **Interface Integrity** enforces backwards compatibility. Every historical
|
||||
command path and alias must still resolve, every historical command must
|
||||
still render `-h`, and historical flags must keep their type and shorthand.
|
||||
New commands, aliases, and flags are allowed. The same job compares the full
|
||||
complete `dws schema --all` contract with the PR merge-base, blocking removed
|
||||
products/tools/parameters, incompatible parameter or interface mappings,
|
||||
constraint drift, and safety-semantic drift. It also checks that executable
|
||||
`dws ...` references in `skills/**/*.md` resolve to real commands.
|
||||
Help compatibility covers command/alias/flag spelling, flag type and
|
||||
shorthand; descriptive prose may evolve without breaking the gate.
|
||||
- **Coverage** runs unit tests on every pull request and prints both overall and
|
||||
changed-code statement coverage. During the migration to the 80% repository
|
||||
target, overall coverage may not regress from a profile generated from the
|
||||
merge-base with the same test command, while changed production Go
|
||||
statements must meet 80%. Linux, Windows, and macOS each generate a native
|
||||
coverage profile for changed packages and enforce the threshold against
|
||||
changed files buildable on that platform, so build-tagged source cannot be
|
||||
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
|
||||
variance to avoid failing unchanged code on test-path noise. Set
|
||||
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
|
||||
the overall target fail closed as well.
|
||||
CI generates the candidate, supporting, and merge-base profiles on three
|
||||
independent runners, then downloads all profiles into the aggregate
|
||||
`Coverage` job and applies the same fail-closed gate. The split changes only
|
||||
scheduling: the tested packages, profile contents, merge-base comparison,
|
||||
and final coverage thresholds remain unchanged.
|
||||
- **CLI Smoke** builds the release binary, reads the root command list from the
|
||||
structured Interface contract, and renders offline help for every public
|
||||
top-level command. It rejects Cobra's unknown-command root-help fallback and
|
||||
fails when the checked-in development fixture is stale.
|
||||
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
|
||||
(`Initialize -> ListTools -> CallTool`).
|
||||
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes,
|
||||
including policy implementations and the checked-in Interface fixture.
|
||||
It uses `pull_request_target` without checking out PR code, so the policy
|
||||
cannot be bypassed by changing the workflow in the same pull request. The
|
||||
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
|
||||
GitHub rulesets can require it.
|
||||
|
||||
## Running the compatibility gates
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
expects the candidate, policy, shortcut, and merge-base profiles
|
||||
(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and
|
||||
`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local
|
||||
checkout can reproduce the Linux/overall CI gate sequentially with:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
root=$(pwd)
|
||||
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
|
||||
rmdir "$base_worktree"
|
||||
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
|
||||
./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
git worktree add --detach "$base_worktree" "$base_ref"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
```
|
||||
|
||||
The native-platform target likewise expects `PROFILE` to have already been
|
||||
generated on that operating system. CI owns those generation steps; copying
|
||||
only either enforcement command into a clean checkout is intentionally an
|
||||
incomplete invocation.
|
||||
|
||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
||||
`main`. The candidate branch cannot bless a breaking change by editing a
|
||||
fixture. Schema additions are allowed; historical products, tools, parameters,
|
||||
parameter mappings, positional execution fields, constraints, and safety
|
||||
semantics remain protected. Positional descriptions are documentation and may
|
||||
change without breaking compatibility.
|
||||
|
||||
`make update-interface-baseline` still extends the local checked-in Interface
|
||||
fixture used by `make interface-integrity`. Updates are monotonic: they add new
|
||||
commands and flags without removing history.
|
||||
|
||||
For an intentional compatibility reset at a major-version boundary, run
|
||||
`make reset-interface-baseline`. This replaces all CLI compatibility history
|
||||
with the current command tree and must receive explicit human review.
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
Create a ruleset for `main` that requires pull requests and code-owner review,
|
||||
then mark these aggregate status checks as required:
|
||||
|
||||
- `CI Gate`
|
||||
- `Multi Profile E2E`
|
||||
- `AI Behavior Check`
|
||||
|
||||
`CI Gate` fails closed unless every first-layer CI job succeeds, including
|
||||
lint, tests, native Linux/Windows/macOS coverage, policy,
|
||||
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
|
||||
check keeps repository rules stable when an internal job is renamed or split.
|
||||
|
||||
The `ai-generated` label must be applied by the PR-creation automation or by a
|
||||
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
|
||||
AI-generated code.
|
||||
@@ -164,16 +164,19 @@ _Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
## `dws contact` — Contact Directory
|
||||
|
||||
_Users, departments, and directory lookups._
|
||||
_Users, departments, directory lookups, and enterprise onboarding._
|
||||
|
||||
**6 commands**
|
||||
**9 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws contact account create` | Create a dedicated login account in the current enterprise. | When the user explicitly asks for an enterprise account or login account, rather than a new enterprise organization. |
|
||||
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
|
||||
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
|
||||
| `dws contact org create` | Create a new DingTalk enterprise organization. | When the user explicitly asks to create or initialize an enterprise and provides its name and creator display name. |
|
||||
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
|
||||
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
|
||||
| `dws contact user invite` | Invite one employee by mobile number into the current enterprise. | When the user explicitly asks to add an employee and has supplied the employee name and mobile number. |
|
||||
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
|
||||
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
|
||||
## 日常只用一个入口
|
||||
|
||||
安装发布 Skill 后直接运行:
|
||||
|
||||
```bash
|
||||
dws-release
|
||||
```
|
||||
|
||||
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
|
||||
|
||||
```bash
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
|
||||
## 发布模型
|
||||
|
||||
```text
|
||||
main 上的候选代码 + beta CHANGELOG
|
||||
→ vX.Y.Z-beta.N(预发验证)
|
||||
→ 只允许补正式 CHANGELOG,源码不得再变化
|
||||
→ vX.Y.Z(正式发布)
|
||||
```
|
||||
|
||||
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
|
||||
## 预发发布
|
||||
|
||||
运行统一入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
beta 验证通过后,运行正式版入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
|
||||
|
||||
```markdown
|
||||
## [1.2.3-beta.1] - 2026-07-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 本次 beta 验证的用户可见变化。
|
||||
```
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。
|
||||
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
|
||||
|
||||
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
@@ -0,0 +1,174 @@
|
||||
# Shortcut 真实测试:后端 / MCP 问题整理
|
||||
|
||||
这份报告只汇总 `failure_category = backend-or-mcp-error` 的 case,已尽量排除权限、缺真实资源、当前账号无数据等噪音。
|
||||
|
||||
## 总览
|
||||
|
||||
- Backend/MCP case 总数:33
|
||||
- 聚合问题数:8
|
||||
- 复现口径:真实 dws CLI;无 mock;无 dry-run;命令输入和 trace_id 均来自真实测试结果。
|
||||
|
||||
## 建议优先看
|
||||
|
||||
1. [P1] Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失(15 case)
|
||||
2. [P1] Chat card 发送 receiverUid 疑似未从 receiver 透传(1 case)
|
||||
3. [P1] Chat 入群审批 applicantUid/inviterUid 疑似未透传(1 case)
|
||||
4. [P1] AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error(5 case)
|
||||
5. [P1] AI 表格 Workflow 查询在真实 Base 下返回系统级错误(2 case)
|
||||
6. [P1] AI 表格 roleId 参数疑似未被 MCP 正确读取(3 case)
|
||||
7. [P2] AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR(2 case)
|
||||
8. [P2] AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR(4 case)
|
||||
|
||||
## Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / IM 后端字段映射
|
||||
- 现象:CLI 已传 group/conversation-id/open-conversation-id(部分 case 使用真实 cid),后端仍报 openCid/openConversationId/cid required。
|
||||
- 期望:MCP schema/网关应接受并透传 openConversationId/openCid/cid 中的兼容字段;如果资源无效,应返回“无效会话”,而不是 required。
|
||||
- 涉及 case:15
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `chat +chat-members-get` | `tools/call` | `2127d89817840997754345760e07bd` | [UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json` |
|
||||
| `read` | `chat +chat-messages` | `tools/call` | `2104a64c17840997767792656e085e` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json` |
|
||||
| `read` | `chat +messages-list` | `tools/call` | `2127d89817840997797873841e0757` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json` |
|
||||
| `write` | `chat +chat-mute-member` | `tools/call` | `2104a64c17840999166036583e08a3` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json` |
|
||||
| `write` | `chat +chat-transfer-owner` | `tools/call` | `0b5deb3217840999222318863e087a` | [UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-messages` | `tools/call` | `2127d89817840999254816721e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-red-point` | `tools/call` | `2104a64c17840999265511295e085f` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-hide` | `tools/call` | `2127d89817840999276117140e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mark-unread` | `tools/call` | `0bb7c36217840999298744910e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mute` | `tools/call` | `2104a64c17840999309241865e085f` | [UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-at-all` | `tools/call` | `2127d89817840999320028068e07dd` | [UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-red-envelope` | `tools/call` | `0bb7c36217840999330228283e07fe` | [UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-set-top` | `tools/call` | `2127d89817840999341302961e07fe` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +messages-set-pin` | `tools/call` | `0bb7c36217840999521117991e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `chat +messages-unset-pin` | `tools/call` | `2104a64c17840999544428103e08ee` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
|
||||
## Chat card 发送 receiverUid 疑似未从 receiver 透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / card 发送参数映射
|
||||
- 现象:CLI 传入 receiver=103262,后端仍报 receiverUid 和 openConversationId 不能同时为空。
|
||||
- 期望:receiver 应映射为 receiverUid,或 schema 明确要求 receiverUid;真实入参不应在 MCP 层丢失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +messages-send-card` | `tools/call` | `2104a64c17840999509255753e081a` | [UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json` |
|
||||
|
||||
## Chat 入群审批 applicantUid/inviterUid 疑似未透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / 入群审批参数映射
|
||||
- 现象:CLI 传入 applicant=103262、inviter=519019,后端仍报 applicantUid required。
|
||||
- 期望:applicant/inviter 应映射为 applicantUid/inviterUid;如果 recordId/group 无效,应返回对应资源错误而不是 applicantUid 缺失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +chat-audit-join` | `tools/call` | `0bb7c36217840999154832733e0758` | [UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json` |
|
||||
|
||||
## AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP wrapper
|
||||
- 现象:多条 AI 表格命令返回 MCP_TOOL_ERROR,内部 JSON 同时出现 success=true、status=error、error 非空。
|
||||
- 期望:只要 error 非空或 status=error,success 应为 false,外层也应按业务错误返回稳定错误码/trace。
|
||||
- 涉及 case:5
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +export-data` | `-` | `2104a64c17840997514714448e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"m… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes` |
|
||||
| `write` | `aitable +chart-update` | `-` | `2106d98117840998553244877e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json` |
|
||||
| `write` | `aitable +record-update` | `-` | `0bab027317840998747383236e090b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"stat… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json` |
|
||||
| `write` | `aitable +record-upsert` | `-` | `2106d98117840998759832182e087b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"e… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json` |
|
||||
| `write` | `aitable +view-set-fill-color-rule` | `-` | `2106d98117840998924181709e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json` |
|
||||
|
||||
## AI 表格 Workflow 查询在真实 Base 下返回系统级错误
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 Workflow MCP / 后端
|
||||
- 现象:使用真实可访问 Base 查询 workflow list/get,返回 LIST_WORKFLOWS_ERROR/GET_WORKFLOW_ERROR。
|
||||
- 期望:无 workflow 时应返回空列表或 WORKFLOW_NOT_FOUND;有后端异常时需提供稳定错误码和可排查 trace。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +workflow-get` | `-` | `2104a64c17840997556363676e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json` |
|
||||
| `read` | `aitable +workflow-list` | `-` | `2127d89817840997572427879e075d` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json` |
|
||||
|
||||
## AI 表格 roleId 参数疑似未被 MCP 正确读取
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP role 接口
|
||||
- 现象:CLI 已传 --role-id,但 MCP 返回 roleId is required。
|
||||
- 期望:role-id/roleId 字段应被正确映射;如果 role 不存在,返回 ROLE_NOT_FOUND,而不是 required。
|
||||
- 涉及 case:3
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +role-get` | `-` | `2104a64c17840997542904838e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json` |
|
||||
| `write` | `aitable +role-delete` | `-` | `2106d98117840998782482701e089c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +role-update` | `-` | `2132f5ca17840998794483634e08d8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json` |
|
||||
|
||||
## AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 primary doc MCP / 后端
|
||||
- 现象:record-query 已能查到真实 recordId,但 primary-doc 查询返回 no record、type=SYSTEM_ERROR。
|
||||
- 期望:若该记录无主文档,应返回空/未创建;若 recordId 语义不匹配,应返回明确参数错误,不应是系统错误。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +base-get-primary-doc-id` | `-` | `0b5deb3217840997466255627e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
| `read` | `aitable +record-primary-doc-get` | `-` | `2127d89817840997528393730e079c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
|
||||
## AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 MCP wrapper / 后端错误码
|
||||
- 现象:安全负向 ID 下,部分写接口返回 getDentryDTO returns null、type=SYSTEM_ERROR、retryable=true。
|
||||
- 期望:资源不存在应返回 INPUT_ERROR/NOT_FOUND 且 retryable=false,避免误导调用方重试。
|
||||
- 涉及 case:4
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `aitable +field-delete` | `-` | `0bab027317840998611338768e08c8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +field-update` | `-` | `213ee25c17840998623207342e08e2` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json` |
|
||||
| `write` | `aitable +record-delete` | `-` | `2132f5ca17840998724753149e0853` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +table-update` | `-` | `213ee25c17840998877246229e087f` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json` |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,279 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Shortcut 真实测试失败逐项 review</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#151d2b;--line:#263246;--text:#dce7f7;--muted:#91a0b5;--blue:#8fd3ff;--green:#66d38a;--yellow:#e2b23c;--red:#f27272;--purple:#d3a7ff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
header{padding:28px 32px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#172033,#0f1420)}
|
||||
h1{margin:0 0 8px;font-size:26px}
|
||||
h2{margin:28px 0 10px;font-size:18px}
|
||||
.sub,.note,.count{color:var(--muted)}
|
||||
.wrap{padding:18px 32px 40px;max-width:1800px;margin:0 auto}
|
||||
.note{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:10px 0 18px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:18px 0}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
|
||||
.stat .n{font-size:24px;color:var(--blue);font-weight:700}
|
||||
.stat .l{color:var(--muted);font-size:12px}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin:16px 0 22px}
|
||||
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
||||
th{background:#1b2536;color:var(--muted);font-size:12px;font-weight:600;position:sticky;top:0;z-index:1}
|
||||
tr:last-child td{border-bottom:none}
|
||||
code{font-family:"SF Mono",Menlo,Consolas,monospace;color:#c7cfdb;font-size:12px}
|
||||
.review{table-layout:fixed}
|
||||
.review th:nth-child(1),.review td:nth-child(1){width:44px}
|
||||
.review th:nth-child(2),.review td:nth-child(2){width:165px}
|
||||
.review th:nth-child(3),.review td:nth-child(3){width:70px}
|
||||
.review th:nth-child(4),.review td:nth-child(4){width:120px}
|
||||
.review th:nth-child(5),.review td:nth-child(5){width:130px}
|
||||
.review th:nth-child(8),.review td:nth-child(8){width:130px}
|
||||
.review th:nth-child(9),.review td:nth-child(9){width:180px}
|
||||
.review td{word-break:break-word}
|
||||
.num{color:var(--muted);text-align:right}
|
||||
.risk{color:var(--green)}
|
||||
.cat{color:var(--yellow)}
|
||||
.owner{color:var(--purple)}
|
||||
.evidence{color:#c7cfdb;font-size:12px}
|
||||
a{color:var(--blue)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Shortcut 真实测试失败逐项 review</h1>
|
||||
<div class="sub">由 <code>scripts/gen_shortcut_error_review.py</code> 从真实测试结果生成;目标是把每个失败项落到“应该改哪里”。</div>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
<div class="note">
|
||||
Read:204 条,成功 162,失败 41,超时 0。
|
||||
Write:162 条,成功 48,失败 114,超时 0。
|
||||
判定口径:如果 fake MCP 已看到字段但真实后端仍报 required,按后端/MCP schema 映射处理;如果真实后端报资源无效/不存在,按 fixture 处理;权限类不在 CLI 中绕过。
|
||||
</div>
|
||||
|
||||
<div class="stats"><div class="stat"><div class="n">41</div><div class="l">Read 失败</div></div>
|
||||
<div class="stat"><div class="n">114</div><div class="l">Write 失败</div></div>
|
||||
<div class="stat"><div class="n">156</div><div class="l">逐项 review</div></div>
|
||||
<div class="stat"><div class="n">72</div><div class="l">测试数据/fixture</div></div>
|
||||
<div class="stat"><div class="n">22</div><div class="l">权限/应用配置</div></div>
|
||||
<div class="stat"><div class="n">33</div><div class="l">后端/MCP schema</div></div></div>
|
||||
<div class="summary-grid">
|
||||
<section><h2>按错误类型</h2><table><thead><tr><th>类型</th><th>数量</th></tr></thead><tbody><tr><td>输入/业务校验</td><td>36</td></tr>
|
||||
<tr><td>后端/MCP</td><td>33</td></tr>
|
||||
<tr><td>缺 AI 表格 fixture</td><td>31</td></tr>
|
||||
<tr><td>缺真实资源</td><td>30</td></tr>
|
||||
<tr><td>鉴权/权限</td><td>22</td></tr>
|
||||
<tr><td>缺妙记 fixture</td><td>3</td></tr>
|
||||
<tr><td>敏感/高风险暂缓</td><td>1</td></tr></tbody></table></section>
|
||||
<section><h2>按要改哪里</h2><table><thead><tr><th>要改哪里</th><th>数量</th></tr></thead><tbody><tr><td>测试数据</td><td>72</td></tr>
|
||||
<tr><td>后端/MCP schema</td><td>33</td></tr>
|
||||
<tr><td>权限/应用配置</td><td>22</td></tr>
|
||||
<tr><td>测试输入/业务校验</td><td>13</td></tr>
|
||||
<tr><td>后端业务/测试 fixture</td><td>11</td></tr>
|
||||
<tr><td>测试输入</td><td>2</td></tr>
|
||||
<tr><td>人工安全确认</td><td>1</td></tr>
|
||||
<tr><td>测试输入/shortcut 枚举</td><td>1</td></tr>
|
||||
<tr><td>测试输入/业务规则</td><td>1</td></tr></tbody></table></section>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>缺真实资源复盘 <span class="count">· 可自造/可查资源处理结果</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>命令/范围</th><th>处理状态</th><th>本次实际排查/造数结果</th><th>后续建议</th></tr></thead>
|
||||
<tbody><tr><td><code>chat +group-members</code></td><td><span class="cat">已补齐</span></td><td>查到真实群名 `浅曦-kida,Dennis,秋画`,runner 已改为用群名而不是 openConversationId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-mget</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊消息 `msgEuOor1PmFBNlx9M06N9z1Q==`;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-read-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊会话 `cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=` 与同一 openMessageId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-query-send-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实发送返回的 openTaskId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>ding +receiver-status</code></td><td><span class="cat">已补齐</span></td><td>先只读 `ding +list` 找到已有 openDingId,再查询 receiver status;没有新发 DING,真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>sheet +list-sheets</code></td><td><span class="cat">已自造</span></td><td>创建临时在线表格 `DWS shortcut 真实测试表格 20260715`,nodeId=`mweZ92PV6O36dZbnsMZx70ylJxEKBD6p`;真实回归成功。</td><td>后续可保留为稳定 fixture,或测试结束后人工清理。</td></tr>
|
||||
<tr><td><code>todo +todo-done</code></td><td><span class="cat">已自造并修复 CLI</span></td><td>runner 会先创建当前账号自己的临时待办,再执行 `todo +todo-done`;同时修复了 todo 列表 pageSize=50 返回空、响应多层 result unwrap 不稳的问题;真实回归成功。</td><td>无需后续动作;代码已有单测覆盖 nested result。</td></tr>
|
||||
<tr><td><code>contact +by-mobile</code></td><td><span class="cat">已按用户授权补齐</span></td><td>使用用户指定手机号 `13161187007` 作为真实 fixture;runner 只在该命令上替换 mobile,不扩散到其它服务。</td><td>真实回归成功后该项将从失败列表移除;若后续要脱敏公开报告,可再加展示层脱敏。</td></tr>
|
||||
<tr><td><code>attendance +get-class / +get-group / +get-group-filtered</code></td><td><span class="cat">不建议自造</span></td><td>`attendance +search-class` 与 `+search-group --type FIXED` 均返回空;创建班次/考勤组会改组织考勤配置,属于高影响业务数据。</td><td>需要考勤后端/业务同学提供可读测试班次与考勤组 ID。</td></tr>
|
||||
<tr><td><code>chat +chat-get-by-id</code></td><td><span class="cat">暂未找到</span></td><td>该 shortcut 只接受数字 groupId;真实群列表只返回 openConversationId,没有数字群号字段。</td><td>需要 IM 后端提供可用数字 groupId,或评估是否新增 openConversationId 形态的 shortcut。</td></tr>
|
||||
<tr><td><code>chat +messages-resource-url</code></td><td><span class="cat">暂未自造</span></td><td>需要真实含 mediaId 的图片/文件/视频消息;当前文本消息无法产生 resource-id。</td><td>可在测试群发一条图片/文件消息并提取 mediaId 后补 fixture;注意会产生群消息。</td></tr>
|
||||
<tr><td><code>chat +thread-replies</code></td><td><span class="cat">暂未自造</span></td><td>需要真实话题消息 topicId;普通群消息不能替代。</td><td>需要话题群 fixture,或由 IM 同学提供当前账号可访问 topicId。</td></tr>
|
||||
<tr><td><code>aitable +dashboard-share-get</code></td><td><span class="cat">真实资源仍失败</span></td><td>已有真实 base/dashboard,但 share-get 返回 404 `Failed to get dashboard share config`;更像分享配置未开启或后端接口行为问题。</td><td>需要 AI 表格/后端确认如何创建/开启 dashboard share fixture,或修正 404 语义。</td></tr>
|
||||
<tr><td><code>write 类 delete/recall/approve/wiki move/copy 等</code></td><td><span class="cat">不自动自造</span></td><td>这些命令即便能造资源,也会涉及删除、撤回、审批通过、知识库移动/复制等高影响动作。</td><td>需要逐项授权和专门测试空间/机器人/审批单据,不建议混在批量回归里自动跑。</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Read 失败逐项 <span class="count">· 42 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +base-get-primary-doc-id</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +chart-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +chart-share-get`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"403","message":"Forbidden","retryable":false,"type":"AUTH_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get chart share config for chart widget-dlxFo…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +dashboard-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +dashboard-share-get`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"Not Found","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get dashboard share config for dashboard KY9…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +export-data</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +record-primary-doc-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +role-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get role because roleId …</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +workflow-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get workflow in base 'gpG2Nd…</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +workflow-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to list workflows in base 'gpG…</td></tr>
|
||||
<tr><td class="num">9</td><td><code>attendance +get-class</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2127d89817840997588238831e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">10</td><td><code>attendance +get-global-setting</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +get-global-setting`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997604718062e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">11</td><td><code>attendance +get-group</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997620512305e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">12</td><td><code>attendance +get-group-filtered</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997638062468e08c7</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">13</td><td><code>attendance +get-leave-balance</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `attendance +get-leave-balance` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840997652901307e081a</code></td><td class="evidence">[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">14</td><td><code>attendance +list-report-columns</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +list-report-columns`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840997666188472e0756</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">15</td><td><code>attendance +query-report-leave</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +query-report-leave`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997679973065e085f</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">16</td><td><code>calendar +find-room</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997694975303e0758</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">17</td><td><code>calendar +room-find</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997709913005e0819</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">18</td><td><code>chat +category-list-conversations</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +category-list-conversations` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997724375834e0758</code></td><td class="evidence">[UNCLASSIFIED] listConversationsByCategoryV2 error hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">19</td><td><code>chat +chat-get-by-id</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-get-by-id`。</td><td><code>tools/call</code></td><td><code>2127d89817840997739165535e07bd</code></td><td class="evidence">[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">20</td><td><code>chat +chat-members-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997754345760e07bd</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">21</td><td><code>chat +chat-messages</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840997767792656e085e</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">22</td><td><code>chat +messages-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997797873841e0757</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">23</td><td><code>chat +messages-resource-url</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-resource-url`。</td><td><code>tools/call</code></td><td><code>2127d89817840997855423145e07dd</code></td><td class="evidence">[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">24</td><td><code>chat +search-msg</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997866824643e0853</code></td><td class="evidence">[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">25</td><td><code>chat +thread-replies</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +thread-replies`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997883504915e0853</code></td><td class="evidence">[UNCLASSIFIED] failed to decrypt openConvThreadId hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">26</td><td><code>contact +get-roster</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +get-roster`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2106d98117840997920166915e087b</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">27</td><td><code>contact +list-roster-fields</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +list-roster-fields`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>213ee25c17840997934295673e08e2</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">28</td><td><code>devapp +credentials-get</code></td><td><span class="risk">read</span></td><td><span class="cat">敏感/高风险暂缓</span></td><td><span class="owner">人工安全确认</span></td><td>该项涉及敏感读取或无安全负向目标,不适合自动用真实资源跑;需要在安全环境逐项人工确认。</td><td>人工确认后单独重跑 `devapp +credentials-get`,并避免在报告中泄露密钥/凭证。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。</td></tr>
|
||||
<tr><td class="num">29</td><td><code>drive +download</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +download` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bab027317840997956923965e08c9</code></td><td class="evidence">[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">30</td><td><code>drive +list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +list` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2106d98117840997968627612e087b</code></td><td class="evidence">[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">31</td><td><code>minutes +action-items</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">32</td><td><code>minutes +latest-minutes</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">33</td><td><code>minutes +minutes-search</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没搜到妙记</td></tr>
|
||||
<tr><td class="num">34</td><td><code>minutes +transcript</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">35</td><td><code>oa +done-approvals</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有已处理的审批记录</td></tr>
|
||||
<tr><td class="num">36</td><td><code>oa +pending</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">当前没有待我审批的任务</td></tr>
|
||||
<tr><td class="num">37</td><td><code>report +report-latest</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无日志</td></tr>
|
||||
<tr><td class="num">38</td><td><code>todo +due-today</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">今天没有到期的待办</td></tr>
|
||||
<tr><td class="num">39</td><td><code>todo +related-tasks</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有与你相关的待办(creator/executor/participant 三种角色下均为空)</td></tr>
|
||||
<tr><td class="num">40</td><td><code>wiki +node-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2132f5ca17840998189126304e08d9</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">41</td><td><code>wiki +resolve-space</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有找到名称包含 DWS shortcut 真实测试 的知识空间</td></tr>
|
||||
<tr><td class="num">42</td><td><code>wiki +space-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `wiki +space-list` 并比较 stdout/stderr。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">参数 --type 取值 "ALL" 不合法,允许值:orgWikiSpace, myWikiSpace</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Write 失败逐项 <span class="count">· 114 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +advperm-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-disable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set adv…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +advperm-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-enable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set adv…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +attachment-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +base-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":null,"message":"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000","retryable":null,"type":"USER_ERROR"},"meta":{},"status":"error","success":true,"summary":"Invalid sou…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +base-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +base-delete`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"52600003","message":"Data not found","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete base DWSREALTESTNOSUCHID000…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +base-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +chart-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +chart-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">9</td><td><code>aitable +chart-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update chart because con…</td></tr>
|
||||
<tr><td class="num">10</td><td><code>aitable +dashboard-arrange</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to align d…</td></tr>
|
||||
<tr><td class="num">11</td><td><code>aitable +dashboard-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">12</td><td><code>aitable +dashboard-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">13</td><td><code>aitable +dashboard-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">14</td><td><code>aitable +field-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get current field info befor…</td></tr>
|
||||
<tr><td class="num">15</td><td><code>aitable +field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get current field info for u…</td></tr>
|
||||
<tr><td class="num">16</td><td><code>aitable +form-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">17</td><td><code>aitable +form-field-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">18</td><td><code>aitable +form-field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">19</td><td><code>aitable +form-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">20</td><td><code>aitable +form-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">21</td><td><code>aitable +import-data</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +import-data`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_IMPORT_ID","message":"importId not found: either invalid or expired","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"impo…</td></tr>
|
||||
<tr><td class="num">22</td><td><code>aitable +import-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"无法解析 baseId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"无效的 baseId","trace_id":"0bab027317840998…</td></tr>
|
||||
<tr><td class="num">23</td><td><code>aitable +record-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete records","trace_id":"…</td></tr>
|
||||
<tr><td class="num">24</td><td><code>aitable +record-primary-doc-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"RESOLVE_DOC_ID_ERROR","message":"Failed to resolve docId from baseId","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to c…</td></tr>
|
||||
<tr><td class="num">25</td><td><code>aitable +record-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Fa…</td></tr>
|
||||
<tr><td class="num">26</td><td><code>aitable +record-upsert</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"records …</td></tr>
|
||||
<tr><td class="num">27</td><td><code>aitable +role-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to create …</td></tr>
|
||||
<tr><td class="num">28</td><td><code>aitable +role-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete role because role…</td></tr>
|
||||
<tr><td class="num">29</td><td><code>aitable +role-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to patch role because roleI…</td></tr>
|
||||
<tr><td class="num">30</td><td><code>aitable +section-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to create …</td></tr>
|
||||
<tr><td class="num">31</td><td><code>aitable +section-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">32</td><td><code>aitable +section-move-node</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to move no…</td></tr>
|
||||
<tr><td class="num">33</td><td><code>aitable +section-rename</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to rename …</td></tr>
|
||||
<tr><td class="num">34</td><td><code>aitable +section-reorder</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to reorder…</td></tr>
|
||||
<tr><td class="num">35</td><td><code>aitable +table-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">36</td><td><code>aitable +table-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update table DWSREALTESTNOSU…</td></tr>
|
||||
<tr><td class="num">37</td><td><code>aitable +view-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">38</td><td><code>aitable +view-duplicate</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to duplica…</td></tr>
|
||||
<tr><td class="num">39</td><td><code>aitable +view-lock</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to lock_or…</td></tr>
|
||||
<tr><td class="num">40</td><td><code>aitable +view-set-fill-color-rule</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set fill col…</td></tr>
|
||||
<tr><td class="num">41</td><td><code>aitable +view-set-frozen-cols</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set fro…</td></tr>
|
||||
<tr><td class="num">42</td><td><code>aitable +view-set-row-height</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set cel…</td></tr>
|
||||
<tr><td class="num">43</td><td><code>aitable +view-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">44</td><td><code>aitable +workflow-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid","retryable":false,"type":"INPUT_ERROR"},"meta":{},"sta…</td></tr>
|
||||
<tr><td class="num">45</td><td><code>aitable +workflow-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid","retryable":false,"type":"INPUT_ERROR"},"meta":{},"sta…</td></tr>
|
||||
<tr><td class="num">46</td><td><code>attendance +boss-check</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>213ee25c17840998998942184e087d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">47</td><td><code>attendance +create-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0bab027317840999009926838e090b</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">48</td><td><code>attendance +create-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2106d98117840999021121258e08b8</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">49</td><td><code>attendance +import-schedule</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999034845189e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">50</td><td><code>attendance +save-leave-balance</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +save-leave-balance`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999045751134e079c</code></td><td class="evidence">[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">51</td><td><code>attendance +update-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999058236471e08b5</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">52</td><td><code>attendance +update-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999068826691e087a</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">53</td><td><code>attendance +update-group-members</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999081094644e07dd</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">54</td><td><code>attendance +update-leave-type</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +update-leave-type`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999093924897e07fe</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">55</td><td><code>calendar +respond-event</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `calendar +respond-event`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999105062121e07db</code></td><td class="evidence">[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">56</td><td><code>chat +category-add-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-add-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999117776203e08f9</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">57</td><td><code>chat +category-remove-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-remove-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999130466520e085e</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">58</td><td><code>chat +chat-add-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +chat-add-bot`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999144193460e08c7</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">59</td><td><code>chat +chat-audit-join</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999154832733e0758</code></td><td class="evidence">[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">60</td><td><code>chat +chat-mute-member</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999166036583e08a3</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">61</td><td><code>chat +chat-quit</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-quit` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999176728426e0779</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">62</td><td><code>chat +chat-remove-bot</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-remove-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999189888305e08b5</code></td><td class="evidence">[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">63</td><td><code>chat +chat-role-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999200483204e079c</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">64</td><td><code>chat +chat-role-remove-user</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove-user` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999211317952e0757</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">65</td><td><code>chat +chat-transfer-owner</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999222318863e087a</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">66</td><td><code>chat +chat-update-icon</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-update-icon` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840999232478654e0819</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">67</td><td><code>chat +chat-update-settings</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/shortcut 枚举</span></td><td>把测试输入的 setting-key 从 x 改为后端支持的 key;同时可在 shortcut flag 上补 enum,避免用户传非法 key。</td><td>改 runner 后重跑;如果补 enum,跑 shortcut 单测确认校验文案。</td><td><code>tools/call</code></td><td><code>2127d89817840999244326971e07dd</code></td><td class="evidence">[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">68</td><td><code>chat +conversation-clear-messages</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999254816721e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">69</td><td><code>chat +conversation-clear-red-point</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999265511295e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">70</td><td><code>chat +conversation-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999276117140e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">71</td><td><code>chat +conversation-mark-read</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2127d89817840999287654280e07bd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">72</td><td><code>chat +conversation-mark-unread</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999298744910e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">73</td><td><code>chat +conversation-mute</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999309241865e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">74</td><td><code>chat +conversation-mute-at-all</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999320028068e07dd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">75</td><td><code>chat +conversation-mute-red-envelope</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999330228283e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">76</td><td><code>chat +conversation-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999341302961e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">77</td><td><code>chat +messages-add-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999352941910e0756</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">78</td><td><code>chat +messages-add-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999362862711e08b5</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">79</td><td><code>chat +messages-batch-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-recall-by-bot`。</td><td><code>tools/call</code></td><td><code>2104a64c17840999373456305e0817</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">80</td><td><code>chat +messages-batch-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999385748776e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">81</td><td><code>chat +messages-combine-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999396596163e08ee</code></td><td class="evidence">[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">82</td><td><code>chat +messages-create-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务规则</span></td><td>换成后端支持的文字表情组合,或把该命令保留为业务负向;CLI 不应绕过后端限制。</td><td>用一个真实可保存的表情模板重跑。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999407422891e08cd</code></td><td class="evidence">[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">83</td><td><code>chat +messages-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999417966407e08ee</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">84</td><td><code>chat +messages-forward-topic</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999428541474e07dd</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">85</td><td><code>chat +messages-recall</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999441138103e08c7</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">86</td><td><code>chat +messages-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999454382709e08a3</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">87</td><td><code>chat +messages-remove-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999467733059e08f9</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">88</td><td><code>chat +messages-remove-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999478923406e087f</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">89</td><td><code>chat +messages-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999494005921e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">90</td><td><code>chat +messages-send-card</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999509255753e081a</code></td><td class="evidence">[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">91</td><td><code>chat +messages-set-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999521117991e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">92</td><td><code>chat +messages-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999532438476e0817</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">93</td><td><code>chat +messages-unset-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999544428103e08ee</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">94</td><td><code>chat +messages-unset-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999554154221e08f9</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">95</td><td><code>devapp +event-subscribe</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-subscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999564022020e08c7</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">96</td><td><code>devapp +event-unsubscribe</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-unsubscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999575698500e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">97</td><td><code>devapp +permission-add</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-add`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999587758457e079c</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">98</td><td><code>devapp +permission-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-remove`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999598445247e085e</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">99</td><td><code>devapp +robot-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999609828961e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">100</td><td><code>devapp +robot-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-disable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999620461113e08ee</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">101</td><td><code>devapp +robot-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-enable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999632641525e0758</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">102</td><td><code>devapp +security-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +security-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999645522046e0817</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">103</td><td><code>devapp +version-create</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999656705920e0853</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">104</td><td><code>devapp +version-publish</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999667906017e075d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">105</td><td><code>ding +send-by-message</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `ding +send-by-message` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999678466213e0853</code></td><td class="evidence">[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">106</td><td><code>doc +comment-create-inline</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>2127d89817840999689931893e079c</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">107</td><td><code>doc +template-apply</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `doc +template-apply`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999701186954e0757</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">108</td><td><code>minutes +record-pause</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-pause` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999713124987e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">109</td><td><code>minutes +record-resume</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-resume` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999724452848e0758</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">110</td><td><code>minutes +record-stop</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-stop` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999735397508e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">111</td><td><code>oa +approve-by</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `oa +approve-by`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到待审批单据:待我处理的审批里没有标题/单号包含 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__" 的单据。</td></tr>
|
||||
<tr><td class="num">112</td><td><code>wiki +node-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999769818495e0779</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">113</td><td><code>wiki +node-move</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999780286509e07fe</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">114</td><td><code>wiki +wiki-new-doc</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `wiki +wiki-new-doc`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到名为 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__" 的知识库;换个更完整/精确的空间名再试。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
# DWS Shortcut — 方法论与进展交接文档(换会话续跑用)
|
||||
|
||||
> 目的:换新会话直接照此续跑。记录**方法论、已完成进展、如何继续、关键坑位、验证命令**。
|
||||
> 分支:`feature/shortcut`(**改动全部未提交**,commit 由用户主动决定)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话现状
|
||||
|
||||
> 2026-07-09 **去冗余(重大修订)**:复盘发现 `internal/helpers/` 早有 ~697 个 `dws <svc> <verb>` 产品命令封装了 281 个 tool;1:1 shortcut 层有 235 个 tool 与之重复。按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 **213 条纯重复 shortcut**,1:1 层 511→298、总数 579→366、服务 19→16(aisearch/live/devdoc 整包移除,其 dws 命令仍由 helper 提供)。全绿+真机复验保留命令可用。**教训:建封装层前先审已有封装。**
|
||||
|
||||
|
||||
在 `internal/shortcut/` 下建成一套声明式 shortcut 体系:**366 条命令 = 298 条 1:1 封装 + 68 条真·智能编排**(1:1 层原 511,已删 213 条纯重复——helper 层早已封装同一批 tool),另有 **~60 条封装升级到 lark 输出投影保真度**、P2 高频自动沉淀闭环、深度对齐 lark 矩阵。**全绿**(build/gofmt/vet/shortcut 全量测试 `shortcuts=366 assembled=322 validated=44 failed=0`/app 全量回归 72s/真机抽验)。
|
||||
|
||||
> 2026-07-09 批33(净新增 1 条·+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段,list_calendar_events + 合并忙碌区间 + 工作窗口求补集,默认今天 09:00-18:00/--from/--to/--in-days)。真机验证:今日 4 段空档(09:00-09:15/15min、12:00-13:30/90min、14:00-14:30/30min、16:00-17:15/75min),正是忙碌事件的精确补集。conflicts+free-slots 构成真实排期智能。总数 578→579、smart 67→68。全绿。
|
||||
|
||||
> 2026-07-09 批32b(净新增 1 条·dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两 [start,end) 重叠检测,默认今天/--in-days)。真机验证:抓到今日 9 个日程里 2 处真实冲突(技术标评审 10:00-11:00 × AIX 共创 10:30-12:00;尖角班 14:30-15:30 × 中控项目 15:00-16:00)。证明复杂写死胡同之外,纯 MCP-tool 的本地编排仍有净新增价值。总数 577→578、smart 66→67。全绿。
|
||||
|
||||
> 2026-07-09 批32(review lark 复杂写类 + 架构边界结论,用户指定方向):fresh review lark 复杂写 shortcut(mail +send/+reply/+forward、drive +import、doc +media-insert/download、base +record-upload-attachment),交叉 dws helpers。**关键结论——架构性死胡同,非没使劲**:① `mail +send` dws 已有 1:1 `+send`(send_email)+`+draft-*`,且 **contact 无 email 字段**→无法按名解析收件人(矩阵早 skip),也无 signature/template/lint 工具;② `drive +import`/`doc +media-*`/`base +record-upload-attachment` 核心是**本地文件字节 PUT/下载落盘**——dws 里由 helper 内部 `httpPutFile`/`http PUT/GET`(drive.go/doc.go)实现、已在 1:1 层覆盖(如 `drive upload --convert`),但 **shortcut 框架 `rt.CallMCP/CallMCPData` 只编排 MCP tool、结构上做不了原始文件 I/O**,故无法在 smart 层组合。**建议**:剩余复杂写要么卡此边界、要么已被 1:1 覆盖;真要补文件类能力应在 helper/1:1 层加命令,而非 shortcut 层。本批 review、无代码改动,总数仍 577。注:本轮触及 session 限额(8:20pm 重置)+ 分类器一度不可用,Bash 受限。
|
||||
|
||||
> 2026-07-09 批31(净新增 1 条):`calendar +my-free`(我自己的忙闲,自动解析当前 userId、默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名)。真机正向验证:返回今日/明日真实忙碌时段 {busy:[{start,end}],userId,free}。总数 576→577、smart 65→66。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批30(净新增 1 条):`contact +me`(当前用户 `get_current_user_profile` + 投影 `{name,userId,mobile,dept,org,email}`,agent 的「我是谁」;区别于 1:1 `+get-self` 吐冗长 `result[].orgEmployeeModel` raw)。真机正向验证:董鑫阳/202397/模型算法/钉钉。总数 575→576、smart 64→65。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批29(净新增便利读 3 条 + 真机抓修 1 bug):`oa +done-approvals`(审批历史 get_done_tasks)、`mail +recent-mail`(近期收件 list_mailbox_threads + 解析绑定邮箱/收件箱 folder)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance)。**真机抓到并修复 bug**:`+done-approvals` 原来 --limit 不传时 pageSize=0 → 后端 business error(1:1 list-executed 有默认所以正常);改为默认 pageSize=20,真机复验走空路径「没有已处理的审批记录」。+this-month 真机有效空、+recent-mail 正确报未绑定邮箱。总数 572→575、smart 61→64。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批28(净新增便利读 smart,多 agent 并行 + 手工):再建 3 条只读 smart——`oa +pending`(`list_pending_approvals` 只读列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(`get_user_todos_in_current_org` + `planFinishDateStart/End` 服务端过滤今天到期,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。真机:+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper。3 条为 dws 原生便利读、不对应 lark gap,矩阵 42/48 不变,总数 569→572、smart 58→61。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批27(写类输出扫荡收尾,确认无更多 bug):扫 smart 里丢弃 CallMCPData 结果的 3 处——`broadcast`(per-recipient 循环、最终结构化输出,OK)、`book`(弃 add-participant 结果但最终 `get_calendar_detail` 确认 + 失败回滚,OK)、`reschedule`(弃存在性 check detail 是有意的,随后打 update 结果,OK)。**无更多 silent-success bug**。结论:**输出质量扫荡完成**,只读投影 clean、写类确认结果、honor --format。剩余仅复杂 net-new gap-buildable(mail +send/drive +import 等多步写、难安全真机验)或 commit。真机+一致性改进累计 13 条,总数 569,全绿。
|
||||
|
||||
> 2026-07-09 批26(写类 smart 输出一致性批量修):扫 smart 里用 `fmt.Print*` / 无标准输出的。修 2 条:`chat +broadcast`(`fmt.Printf` 群发摘要忽略 --format → `rt.Output({sentCount,failedCount,sent,failed})`);`wiki +wiki-new-doc`(**原创建文档后丢弃 create_file 结果、静默 return nil**,真 UX bug 拿不到新文档 id/url → 捕获并 `rt.Output({created,space,title,result})`)。写类无法真机验(会真建/发),assemble 测试确认组装正确、低风险。总数仍 569。
|
||||
|
||||
> 2026-07-09 批25(+next-event 输出一致性修复 + sweep 确认多数已 clean):sweep 探 chat +conversation-list/+category-list、aitable +base-list 等——**多数 1:1 只读命令输出已 clean**(属先前 ~60 升级覆盖),保真度工作基本到位。**修复 1 条一致性**:`calendar +next-event` 原用 `fmt.Println` 打固定文本行、**忽略 --format/--jq/--fields**,改为 `rt.Output(map{event:项目投影})`(复用 +today/+week 同款投影),真机复验 `--format json` 出结构化 `{event:{title,start,end,location,eventId}}`、`--jq '.event.title'` 可用。这是 Agent 友好性修复。同时删除死代码 `shortcutNextEventSummary` + 无用 fmt import。总数仍 569。
|
||||
|
||||
> 2026-07-09 批24(1:1 层保真度升级续):`contact +list-followings` 原 `rt.CallMCP` 吐 `{arguments,result:{models:[…]}}` 信封噪音,改为 `CallMCPData`+`listFollowingsProject`,真机复验干净 `{count:13, followings:[{openDingTalkId}]}`。总数仍 569。**判断**:真机验证 + 保真度升级已到深度边际收益区(本批仅拍平 ID 列表);1:1 层多数只读命令要么需特定参数、要么后端权限受限、要么输出已可接受。**建议优先 commit 留存 24 批成果**(10 个真机改进 + 58 smart + 保真度升级 + P2 + 全套文档),再按需推进剩余 1:1 微升级。
|
||||
|
||||
> 2026-07-09 批23(验证驱动的 1:1 层保真度升级起步):真机探 1:1 只读命令,`drive +recent` 原 `rt.CallMCP` 吐冗长 raw(logId/nextCursor 噪音 + 每项巨型 docUrl + hasMore),改为 `CallMCPData`+`recentListProject`:投影 `{count, hasMore, items:[{name,nodeType,contentType,accessTime,docUrl,nodeId}], nextCursor}`,去 logId 噪音、保留分页与链接,真机复验干净。`drive +list-spaces` 真机空(有 errorCode 包裹噪音但 result.items 为空,暂不动)。总数仍 569。**注**:1:1 层仍有数十个 list 命令可类似升级,但属边际收益、量大,建议按需/被动推进,优先 commit 留存已有成果。
|
||||
|
||||
> 2026-07-09 批22(报告综合更新,反映真机验证战役):给 `shortcut-report.md` 新增 §2.4「真机验证战役」:记录 9 批真机验证(正向验证 20+ 条、抓修 8 个真实 bug 的表格、后端受限项、resolveUser 非 bug 澄清);`shortcut-report.html` §③ 测试表补 2 行 + 一段说明。诚实反映「assemble 合成测试盲区 → 真机验证补齐」的价值。纯文档,无代码改动,总数仍 569。
|
||||
|
||||
> 2026-07-09 批21(find-record 验证 + +suggest-time 保真度升级):`aitable +find-record` 真机正常(返回真实记录;cells 按字段 ID 键值、内含附件对象,天然复杂,clean 投影需 field-id→name 解析属更大改造,暂留)。**升级 1 条**:`calendar +suggest-time` 原 `rt.CallMCP` 吐 `result.recommendEventTimes[]` 且 `timeConflictAttendees:[null]` 噪音,改为 `CallMCPData`+`suggestTimeSlots`+`Output`:拍平 result、丢弃 null 冲突项,真机复验干净 `{suggestions:[{start,end}]}`(有真实冲突时才带 conflicts)。总数仍 569。**说明**:真机验证扫荡已进入边际收益递减区(明显 raw-verbose 的 wart 基本清完),剩余多为 minutes org-gated、写类、或输出已可接受。列出 12 条只读仍用 raw `rt.CallMCP` 的 smart(多为 minutes org-gated 或已验证 clean)。**升级 1 条**:`calendar +today` 原直吐 17 字段冗长事件(含完整 attendees 数组),改为 `CallMCPData`+复用 `+week` 的 `shortcutNextEventList/Start` 投影,真机复验干净输出 `{events:[{title,start,end,location,eventId}]}`(与 +week 一致 + location)。总数仍 569。剩余 raw-CallMCP 只读 smart:action-items/latest-minutes/transcript(minutes org-gated 无法真机验)、org/report-latest(已验 clean)、find-record/suggest-time/by-mobile/lookup/team(待验或权限受限)。
|
||||
|
||||
> 2026-07-09 批19(日历只读 smart 验证 + +free 保真度升级):`calendar +next-event` 真机正常(可读摘要「下一个日程:致拓 AI FDE 经验分享…」,但**忽略 --format json 只吐文本**,已知小瑕疵未改)。**升级 1 条**:`calendar +free` 终结步原 `rt.CallMCP` 直吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套,改为 `CallMCPData`+`freebusySlots`+`Output`,真机复验干净输出 `{who,userId,free,busy:[{start,end}]}`(董鑫阳 2026-07-10 忙 4 段)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批18(真机验证续 + +group-members 保真度升级):**验证正常**:`contact +org`(董鑫阳→模型算法/17人,3步链)、`drive +find-file`(干净投影 {dentryId,fileSize,name,type})。**后端受限(非 bug)**:`contact +team`(列部门成员 `PAT_MEDIUM_RISK_NO_PERMISSION`)、`chat +search-msg`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。**升级 1 条**:`chat +group-members` 终结步原用 `rt.CallMCP`(直吐原始冗长 `result.list[]` + memberAvatarMediaId + arguments/errorCode 噪音),改为 `CallMCPData`+`groupMemberProject`+`Output`,真机复验干净输出 `{count, members:[{name,nick,role,openDingtalkId}]}`(刘力/怒龙/群主…)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批17(修复批16 发现的 +at-me 投影):`chat +at-me` 原来因 `atMeMessageItems` 不认识真实两层嵌套 `result.conversationMessagesList[].messages[]` → 命中 fallback、直接吐原始结构。真机 dump 出真实结构(group 有 title/openConversationId/messages;message 有 sender/content/createTime/openConversationId),新增 `atMeFlattenGroups` 把各会话组拍平成单一消息列表、并把组的会话 title 下沉到每条消息。真机复验:43 条消息干净投影为 `{conversation,sender,text,time}`(如 conversation:"AI全栈"、sender:"龙衔")。总数仍 569。
|
||||
|
||||
> 2026-07-09 批16(只读 smart 真机验证扫荡 + 质量修复):真机跑一批时间/自身类只读 smart。**验证正常**:`calendar +today`(真实日程+参会人)、`calendar +week`(干净投影)、`todo +overdue`(空)、`attendance +my-attendance`(空)、`report +report-latest`("暂无日志"空路径)、`oa +my-initiated`(真实审批数据)。**修复 1 个输出 wart**:`chat +unread-chats` 每行都吐 `unread: null`——因 `unread_message_conversation_list` 根本不返回每会话未读数(在列表里即代表未读),改为「仅当 gateway 真返回未读数时才带 unread 字段」,真机复验输出已干净 `{conversationId,name}`。**已知待优化(未改)**:`chat +at-me` 返回 `result.conversationMessagesList[].messages[]` 冗长嵌套原始结构、未拍平成干净消息列表(功能正常,投影可再优化)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批15(质量修复 + resolveUser 排查,真机):**修复** `contact +dept-members` 消歧消息 `<red>` 标记泄漏——复用 `stripHighlightTags`(resolve_dept.go)在 name 提取处剥离,真机复验消息已干净("开放平台(666202009)、技术平台-开放平台研发(1085781688)…")。注:`dept_members.go` 本身容器解析(含 deptList)+数值 deptId 早已健壮,仅 name markup 未剥。**排查澄清(非 bug)**:`resolveUser` 对 `董鑫阳` 真机端到端正常(userId 202397、部门 模型算法);但对 `秋画` 这类联系人 `search_contact_by_key_word` 返回 name/userId 全 null(仅 openDingTalkId),resolveUser 正确报「没找到」而非瞎猜——这是钉钉数据模型现实(外部/受限联系人无 userId),非代码 bug。**已知限制**:按名解析仅对「搜索能返回 userId 的组织内成员」有效。总数仍 569。
|
||||
|
||||
> 2026-07-09 批14(真机验证续,需具体 ID 的只读 shortcut):**正向验证过**:`chat +my-groups`(98 真实群+投影)、`aitable +base-list`/`+list-tables`(真实 base/table)、`aitable +resolve-table`(单命中 通用→99dV75A、多候选消歧,容器 key `tables` 正确,无 deptList-class bug)。**后端权限受限、无法正向验证(非代码 bug)**:`chat +chat-messages`(`PAT_MEDIUM_RISK_NO_PERMISSION`,读会话消息需更高权限)、`minutes +*`(该 org 未开启 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。结论:可验证的 read/resolve shortcut 全部投影正确,仅批13 的 resolve-dept 有真 bug 已修。
|
||||
|
||||
> 2026-07-09 批13(真机验证 + bug 修复,登录态 corp「钉钉」):用登录态把批9-12 只读 shortcut 打真实后端。**正向验证过**:`doc +find-doc`(10 真实文档、投影干净)、`aitable +resolve-base`(多候选真实 baseId)、`mail +find-mail-user`(命中真实用户+邮箱)、`contact +resolve-dept`(修复后返回真实候选)。**真机抓到并修复 1 个真 bug**:`contact +resolve-dept` 原来对任何真实部门名都返回「未找到」——真实 `search_dept_by_keyword` 响应容器 key 是 **`deptList`**(agent 的探测清单漏了),且 `deptName` 带 `<red>…</red>` 高亮标记、`deptId` 是数值。已修:容器加 `deptList`、`stripHighlightTags` 去标记、deptId 数值 coerce 成串(`resolve_dept.go`),真机复验通过(开放平台→666202009、财务→846624121,名称干净)。**已知遗留(未改)**:`contact +dept-members` 的消歧提示消息里 `<red>` 标记未剥离(仅 cosmetic,功能正常)。总数仍 569,本批未加新命令。
|
||||
|
||||
> 2026-07-09 批12(多 agent 并行,dws 原生 resolver 层):再建 3 条「按名解析 ID」智能 shortcut——`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内本地名→tableId)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,**已修数值 ID 兼容**:deptId 为 JSON number 时 coerce 成串,非 string-only)。均 0/1/多候选消歧,对标 resolveUser 各资源版。这 3 条不对应具体 lark gap(是 dws 原生便利层),故 gap-buildable/covered-smart 矩阵计数不变(42/48),仅总数 566→569、smart 55→58。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批11(多 agent 并行):再建 3 条智能 shortcut——`aitable +resolve-base`(search_bases 按名解析 baseId + 0/1/多候选消歧)、`chat +chat-messages`(群/单聊会话消息 list_conversation_message_v2 / list_individual_chat_message,ExactlyOne 互斥 + 投影)、`mail +find-mail-user`(search_mail_users 按名搜企业邮箱联系人 + 投影)。文档全量同步 566/505/55(gap-buildable 49→42、covered-smart→48)。全绿。注:本批 app 回归首跑因并发负载 flaky FAIL 一次(80s),连跑 2 次稳定 PASS(71s)——非本次改动导致。
|
||||
|
||||
> 2026-07-09 批10(多 agent 并行):再建 3 条智能 shortcut——`chat +thread-replies`(list_topic_replies 拉话题回复 + sender/text/time 投影)、`todo +related-tasks`(get_user_todos_in_current_org 三角色 creator+executor+participant 并集 + taskId 去重 + 投影)、`doc +find-doc`(search_documents 关键词搜文档 + title/url/type/token 投影)。均以 helper 为 ground truth、0 编造。文档全量同步到 563/503/52(report.md/html、lark-alignment.md gap-buildable 49→44、covered-smart→46、comparison.html 重生成)。全量测试 + app 回归全绿。
|
||||
|
||||
> 2026-07-09 续跑增量(批9·手工):新建 3 条智能 shortcut——`minutes +detail`(单命令聚合一条听记 basic/summary/keywords/transcript/todos、partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换、去重校验+逐组聚合)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片≤20/批+跨 `aitable-helper` server fanout+合并)。均以 helper 为 ground truth。**同步刷新全部文档到 560/501/49**:`shortcut-report.md`、`shortcut-report.html`、`shortcut-lark-alignment.md`(gap-buildable 49→46、covered-smart 41→44)、重生成 `shortcut-comparison.html`。全量测试 + app 回归全绿。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
- 对齐基准:`/Users/dennis/Projects/larksuite/cli`(lark-cli 的 `shortcuts/` 框架,飞书 REST API)。
|
||||
- dws 执行底座:**钉钉 MCP**(粗粒度:一个 tool = 一个完整操作)。
|
||||
- 目标:把 lark 的 shortcut 能力**深度对齐每一个**到 dws,并补钉钉侧系统性能力。
|
||||
- 关键认知:lark 的"组合性"多源于飞书 API 细粒度(先查 token→id→再操作);钉钉 MCP 粗粒度,**lark 的多步在钉钉大量塌缩成 1:1(已被封装层覆盖)**。真正需要"编排"的是「按名解析 ID + 多工具串联 + 跨服务」——这些做成了 smart 层。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构与关键文件
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / Risk 声明结构
|
||||
runner.go # RuntimeContext + mount(编译成cobra) + CallMCP/CallMCPData/Output + 校验/dry-run/风险确认
|
||||
validate.go # 跨字段校验 helper:MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll
|
||||
register.go # Register() / Commands() / All()
|
||||
shortcut_test.go# 框架单测
|
||||
builtin/
|
||||
builtin.go # blank-import 所有服务包 + smart 包;Commands() 汇总
|
||||
coverage_test.go # ★全量测试:TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestAllHaveIntent / TestNoDuplicateCommands
|
||||
<service>/ # 19 个服务包:contact/chat/calendar/todo/doc/drive/mail/wiki/minutes/oa/report/attendance/aitable/sheet/devapp/ding/aisearch/live/devdoc
|
||||
<service>.go # 该服务的 1:1 封装 shortcut(var + init(){shortcut.Register(...)})
|
||||
smart/ # ★真·智能层(多步/编排/按名解析/跨服务)
|
||||
resolve.go # resolveUser(rt,name) 名→userId+消歧;contactUser{userID,name};extractUsers/userLabels
|
||||
dm.go lookup.go assign.go book.go free.go ... # 每条一个文件
|
||||
usage/ # P2 埋点:recorder.go(记形状不记值) stats.go command.go(dws shortcut list/stats/suggest/add)
|
||||
userdef/ # P2 自定义 shortcut YAML 运行时加载 loader.go
|
||||
|
||||
internal/app/legacy.go # 接线点:newLegacyPublicCommands 里 append builtin.Commands() + userdef.Load()
|
||||
internal/app/root.go # 装配 recordingToolCaller(埋点) + dws shortcut 命令
|
||||
internal/helpers/*.go # ★Ground truth:钉钉真实 MCP tool 名 + 参数(callMCPTool("tool",{...}))
|
||||
|
||||
docs/
|
||||
shortcut-plan.md # 总规划
|
||||
shortcut-p2-design.md # P2 自动沉淀设计
|
||||
shortcut-report.md / .html # 综合报告 + GSB
|
||||
shortcut-comparison.html # 逐条三方对照(dws vs lark vs 原生MCP)
|
||||
shortcut-lark-alignment.md # ★深度对齐矩阵(lark 361条逐条分析, 49 gap-buildable)
|
||||
shortcut-handoff.md # 本文件
|
||||
scripts/gen_shortcut_comparison.py # 生成三方对照 HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 框架契约(写新 shortcut 必读)
|
||||
|
||||
一个 shortcut = 包级 `var X = shortcut.Shortcut{...}` + `func init(){ shortcut.Register(X) }`。
|
||||
|
||||
```go
|
||||
var SearchUser = shortcut.Shortcut{
|
||||
Service: "contact", // 顶层命令
|
||||
Command: "+search-user", // + 前缀,kebab-case
|
||||
Product: "contact", // MCP server id(默认=Service;注意跨 server,见坑位)
|
||||
Description: "...", // 一行
|
||||
Intent: "自然语言:做什么/何时用/副作用", // 每条必填(TestAllHaveIntent 强制)
|
||||
Risk: shortcut.RiskRead, // Read / Write / HighWrite(删除等,框架二次确认)
|
||||
Flags: []shortcut.Flag{{Name:"query", Type:shortcut.FlagString, Required:true, Desc:"...", Enum:[]string{...}}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error { return rt.RequireAll("query") }, // 可选
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { ... },
|
||||
}
|
||||
```
|
||||
|
||||
RuntimeContext 方法(`internal/shortcut/runner.go`/`validate.go`):
|
||||
- 读参数:`rt.Str/Bool/Int/StrSlice(name)`、`rt.Changed(name)`
|
||||
- **调 MCP 并打印**(终结步,1:1 封装用):`rt.CallMCP(tool, params) error`(用自身 Product)
|
||||
- **调 MCP 拿数据**(多步/投影用,不打印,可跨 server):`rt.CallMCPData(product, tool, params) (map[string]any, error)`
|
||||
- **投影输出**:`rt.Output(payload) error`(吃 --format/--jq/--fields)
|
||||
- 校验:`rt.MutuallyExclusive/AtLeastOne/ExactlyOne(flags...)`、`rt.RangeInt(flag,min,max)`、`rt.RequireAll(flags...)`
|
||||
- smart 复用:`resolveUser(rt, name) (contactUser, error)`(名→userId+消歧,在 smart/resolve.go)
|
||||
|
||||
对标 lark:`CallMCPData`≈`CallAPITyped`;`resolveUser`≈`ResolveOpenIDsTyped`;`rt.Output`≈`OutFormat`;`Validate helper`≈lark 的 MutuallyExclusive/AtLeastOne。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方法论(怎么高效批量建,屡试不爽)
|
||||
|
||||
**核心:多 agent workflow 并行 + helper 为 ground truth + 严格 skip + build/test 门禁。**
|
||||
|
||||
1. **每个 shortcut/服务一个 agent**,并行(`parallel(...)`)。
|
||||
2. **Ground truth 铁律**:tool 名和参数 key **只能逐字取自 `internal/helpers/<svc>.go` 的真实 `callMCPTool("tool",{params})` 调用点**,严禁编造。agent 必须先 Read+grep helper。
|
||||
3. **宁缺勿错**:拿不准的 tool/参数/结构 → **skip 并说明**,不瞎写(已多次证明 agent 会正确 skip,如 mail 无 email 字段)。
|
||||
4. **响应字段防御式解析**:返回结构无契约保证 → 多候选 key 探测(result/data/list/items + 字段别名),不硬编码。
|
||||
5. **不同 agent 写不同文件**(服务包 vs smart 包,或不同 service 文件)→ 无写冲突;**禁止 agent 改 builtin.go**(我事后统一维护 blank import)。
|
||||
6. **落地后统一**:`gofmt -w` → `go build ./...` → shortcut 全量测试 → 命名冲突用 rename 修(如 smart 的 `+approve` 撞 1:1 层 → 改 `+approve-by`)。
|
||||
7. **周期性 app 全量回归**(改多个服务文件后):`go test ./internal/app/...`(~73s)验证接线。
|
||||
|
||||
workflow 脚本模板见任意 `~/.claude/.../workflows/scripts/build-smart-*.js` 或 `upgrade-fidelity-*.js`(每次 Workflow 调用都存了盘,可 `{scriptPath}` 复用/改)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已完成进展
|
||||
|
||||
### 5.1 覆盖层(511 条 1:1 封装 / 19 服务)
|
||||
chat89 aitable86 mail43 attendance36 doc33 minutes31 devapp30 sheet29 calendar24 drive24 oa20 todo18 wiki15 contact14 report7 ding7 aisearch3 live1 devdoc1。每条带自然语言 Intent。
|
||||
|
||||
### 5.2 智能层(~46 条 `internal/shortcut/smart/`)
|
||||
按名操作人/群、多步编排+失败回滚、时间/自身智能、跨服务、钉钉原生编排。已建(举例):
|
||||
`chat +dm/+send-to-group/+broadcast/+group-members/+at-me/+search-msg/+unread-chats`、`contact +lookup/+org/+team/+by-mobile/+dept-members`、`calendar +book(回滚)/+free/+today/+week/+next-event/+invite/+suggest-time/+reschedule/+cancel-event/+respond-event/+find-room`、`todo +assign/+assign-multi/+overdue/+todo-done/+remind/+created-todos`、`minutes +latest-minutes/+action-items/+transcript/+minutes-search/+detail/+replace-batch`、`oa +approve-by/+my-initiated`、`attendance +my-attendance`、`report +report-latest`、`aitable +find-record/+list-tables`、`doc +share-doc/+doc-append`、`wiki +wiki-new-doc`、`drive +find-file`、`mail +search-mail/+unread-mail`。
|
||||
|
||||
### 5.3 框架系统性能力(对齐 lark)
|
||||
`resolveUser`、`CallMCPData`、`rt.Output`、`Validate×5`。
|
||||
|
||||
### 5.4 保真度升级(~64 条封装:CallMCP→CallMCPData+投影+Output,对齐 lark 96% 输出投影)
|
||||
覆盖 contact/chat/calendar/todo/doc/drive/mail/wiki/aitable/oa/devapp/attendance/minutes/report/sheet 等服务的列表类命令。
|
||||
|
||||
### 5.5 P2 高频自动沉淀(差异化,lark 无)
|
||||
埋点(记形状不记值,默认关/opt-in DWS_USAGE_TRACKING=1) → `dws shortcut stats/suggest` → `dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 运行时 `userdef.Load()` 编译注册。**闭环端到端跑通。**
|
||||
|
||||
### 5.6 深度对齐矩阵
|
||||
`docs/shortcut-lark-alignment.md`:逐条分析 lark 361 条 → covered-1to1 144 / no-dingtalk-tool 127 / **gap-buildable 49** / covered-smart 41。
|
||||
|
||||
---
|
||||
|
||||
## 6. 如何继续(下一步 backlog)
|
||||
|
||||
1. **保真度升级剩余列表命令**(还有部分服务的 list 命令仍是裸 CallMCP):起 `upgrade-fidelity-N` workflow,每服务 agent 挑 1-2 个未升级(`grep 'rt.CallMCP('`)的列表读命令,改成 CallMCPData+投影+Output。范式见 `contact.go` 的 `searchUserProject`/`listRolesProject`。
|
||||
2. **补剩余 gap-buildable smart shortcut**(矩阵里 49 个,已建 ~20+):起 `build-smart-N` workflow(smart 包,不碰服务包)。剩余偏复杂(sheets/base 操作、消息富化、分片下载),谨慎、允许 skip。
|
||||
3. **每批**:gofmt→build→shortcut 测试→(改多文件后)app 回归→更新 `docs/shortcut-report.md`。
|
||||
4. **收尾**:把 `docs/shortcut-report.md/html` 的计数刷新到最终(部分 §2 测试数字可能还停在旧值 511/456,实际 ~557/~500),重跑 `python3 scripts/gen_shortcut_comparison.py`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键坑位(务必注意)
|
||||
|
||||
- **跨 server 路由**:有些 tool 不在本服务 server。已知:contact 花名册 tool 走 `hrmregister`;chat 部分 tool 走 `im`/`bot`;`query_check_record` 走 `attendance-wukong`(不是 attendance);wiki `create_file` 走 `doc` server。→ 这类必须用 `rt.CallMCPData("<真实server>", ...)`,不能用 `rt.CallMCP`(它按 shortcut.Product 路由会打错 server)。判断依据:helper 里是 `callMCPToolOnServer("<server>", ...)`。
|
||||
- **命名冲突**:smart 命令别撞 1:1 层(如 `+approve`→用 `+approve-by`,`+freebusy`→用 `+free`)。`TestNoDuplicateCommands` 会抓。
|
||||
- **参数别名**:aitable 查询关键词是 `keyword`(不是 query);todo 建待办用嵌套 `PersonalTodoCreateVO`;日程 create/update 时间是 ISO 字符串,而 list/busy 是毫秒——一切以 helper 调用点为准。
|
||||
- **中文 const tool 名**:minutes 录音 tool 是中文 `"执行听记指令-发起AI听记录音"`(const `listeningNoteCmdTool`)——真实,别当编造。
|
||||
- **测试真机验证**:token 有时效(`dws auth status` 看 expires),过期会报错非代码问题。真机跑命令时第一次有 catalog 发现 banner(stderr),结果 JSON 在后面,别用 `head` 截断。
|
||||
- **工具标签格式**(给 AI:本会话我多次误用错标签导致工具调用失败——务必用正确的 function-call 格式)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验证命令速查
|
||||
|
||||
```bash
|
||||
cd /Users/dennis/Projects/dingtalk-workspace/dingtalk-workspace-cli
|
||||
go build ./... # 编译
|
||||
gofmt -l internal/shortcut/ # 格式(应空)
|
||||
go test ./internal/shortcut/... # shortcut 全量(含 assemble/tool-real/intent/no-dup)
|
||||
go test ./internal/app/... # app 回归(~73s,改服务文件后必跑)
|
||||
go test ./internal/shortcut/builtin/ -run TestAllShortcutsAssemble -v 2>&1 | grep shortcuts= # 看总数
|
||||
|
||||
# 真机(需登录 dws auth login)
|
||||
DWS_USAGE_TRACKING=0 dws contact +lookup --name <真实姓名> # 智能多步示范
|
||||
DWS_USAGE_TRACKING=0 dws calendar +today # 时间智能
|
||||
DWS_USAGE_TRACKING=0 dws contact +search-user --query <名> # 投影输出示范
|
||||
```
|
||||
|
||||
测试口径:`TestAllShortcutsAssemble` = 假 Caller 拦截,给每条命令(含写/删)喂合成参数、走完解析→校验→组装 MCP 调用、断言 tool 真实无 panic(零副作用全量验证)。`TestAllToolLiteralsAreReal` = 所有 CallMCP tool 名比对 helper ground truth 防编造。
|
||||
|
||||
---
|
||||
|
||||
## 9. 未提交提醒
|
||||
|
||||
所有工作在 `feature/shortcut`,**未 commit**。建议尽快分语义化 commit 留存(框架 / 511封装 / smart层 / 保真度升级 / P2 / 文档)。
|
||||
@@ -0,0 +1,187 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -0,0 +1,204 @@
|
||||
# DWS Shortcut P2 详细设计 — 高频场景自动沉淀为自定义 Shortcut
|
||||
|
||||
> 前置:P1 已交付静态声明式 shortcut 框架(`internal/shortcut/`),见 `docs/shortcut-plan.md`。
|
||||
> P2 目标:**观察用户高频使用 → 主动建议 → 一键沉淀为可复用的自定义 shortcut**,
|
||||
> 让 CLI 越用越顺手。这是 dws 相对 larksuite/cli 的差异化能力。
|
||||
|
||||
## 0. 体验闭环(一句话)
|
||||
|
||||
```
|
||||
用户反复敲 dws chat send_message --json '{"open_conversation_id":"cid_x","text":"..."}'
|
||||
│ (每次执行被静默记录到 ~/.dws/usage.jsonl)
|
||||
▼
|
||||
第 N 次后,dws 主动提示:
|
||||
💡 你已 12 次向「项目群」发消息,是否沉淀为 `dws chat +notify-team`?[y/N]
|
||||
│ y
|
||||
▼
|
||||
写入 ~/.dws/shortcuts/chat.notify-team.yaml
|
||||
▼
|
||||
之后:dws chat +notify-team --text "发布完成" ← 参数从一堆 JSON 收敛成一个 flag
|
||||
```
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
复用 P1 的 `Shortcut` 模型作为「编译目标」,新增四个部件:
|
||||
|
||||
| 部件 | 位置(建议) | 职责 |
|
||||
|------|-------------|------|
|
||||
| Usage 埋点 | `internal/shortcut/usage/recorder.go` | 每次 MCP 调用后追加一条 usage 记录 |
|
||||
| 模式挖掘 | `internal/shortcut/usage/miner.go` | 聚合 usage → 高频候选 + 打分 |
|
||||
| 主动提示 | `internal/shortcut/usage/nudge.go` | 命中候选时在命令收尾处提示 |
|
||||
| YAML 加载 | `internal/shortcut/userdef/loader.go` | 扫描 `~/.dws/shortcuts/*.yaml` → 编译成 `Shortcut` → 注册 |
|
||||
| 管理命令 | `internal/shortcut/usage`(cobra) | `dws shortcut list/suggest/add/rm/stats` |
|
||||
|
||||
数据流:
|
||||
|
||||
```
|
||||
CallMCP ──► recorder.Append(usage) [写侧,热路径,必须极轻]
|
||||
┌─► miner.TopCandidates() [读侧,suggest 时才算]
|
||||
~/.dws/usage.jsonl ─────────────────┤
|
||||
└─► nudge (命令收尾抽样触发)
|
||||
~/.dws/shortcuts/*.yaml ──► loader.Compile() ──► shortcut.Register() [启动时]
|
||||
```
|
||||
|
||||
## 2. Usage 埋点
|
||||
|
||||
### 2.1 采集点(choke point)
|
||||
|
||||
**首选**:装饰 `executor.Runner` / `edition.ToolCaller`。`internal/app/tool_caller_adapter.go`
|
||||
的 `CallTool(ctx, productID, toolName, args)` 是**所有 MCP 调用的唯一必经点**,天然拿到
|
||||
`(product, tool, args)` 三元组。用装饰器包一层即可,零侵入命令层:
|
||||
|
||||
```go
|
||||
type recordingCaller struct{ inner edition.ToolCaller }
|
||||
func (r recordingCaller) CallTool(ctx, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
res, err := r.inner.CallTool(ctx, product, tool, args)
|
||||
usage.Append(product, tool, args, err == nil) // 异步/带 recover,绝不影响主流程
|
||||
return res, err
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:P1 的 shortcut 也走这条 `CallMCP → helpers → deps.Caller`,所以内建 shortcut 的
|
||||
> 使用同样会被记录,可用于「哪些内建 shortcut 最受欢迎」的洞察。
|
||||
|
||||
### 2.2 记录内容(**隐私优先:记形状不记值**)
|
||||
|
||||
`~/.dws/usage.jsonl`,每行一条:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-08T10:12:33+08:00",
|
||||
"product": "chat",
|
||||
"tool": "send_message",
|
||||
"arg_keys": ["open_conversation_id", "text"],
|
||||
"const_args": {"open_conversation_id": "cid_x"},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `arg_keys`:参数键集合(排序),用于识别「同一种调用形状」。
|
||||
- `const_args`:**仅收敛出的「疑似固定值」**(见 §3 挖掘时判定),写入时不保证脱敏,
|
||||
因此需要一层白名单/黑名单:`text/content/body/message` 等自由文本字段**永不入库**,
|
||||
只保留看起来像 ID/枚举的短值(长度阈值 + 无空格 + 非多行)。
|
||||
- 绝不记录:token、手机号、邮箱、文件内容、消息正文。用 `internal/logging/redact.go`
|
||||
已有的脱敏能力复核。
|
||||
|
||||
### 2.3 热路径约束
|
||||
|
||||
- 追加写用 `O_APPEND`,单行 < 1KB;失败静默(`recover` + debug 日志),**绝不阻断命令**。
|
||||
- 文件滚动:超过 N 行(如 5000)或 M 天,截断/归档,避免无限增长。
|
||||
- 开关:默认关闭(opt-in),环境变量 `DWS_USAGE_TRACKING=1` 开启(本地遥测即便只记形状也不应未经用户同意默认开启)。
|
||||
首次启用时在 `dws` 首跑给一次性告知(尊重知情)。
|
||||
|
||||
## 3. 模式挖掘
|
||||
|
||||
`dws shortcut suggest` 触发(也被 nudge 复用)。算法:
|
||||
|
||||
1. 读 usage.jsonl,按 `(product, tool, arg_keys)` 分桶。
|
||||
2. 对每桶:
|
||||
- `count` = 出现次数;低于阈值(默认 5)直接丢弃。
|
||||
- 对每个 arg_key,统计其值的分布:某值占比 ≥ 80% → 判定为**固定值**(进 `const_args`);
|
||||
否则判定为**可变参数**(沉淀后成为 flag)。
|
||||
- `recency` = 最近一次使用距今;越近权重越高。
|
||||
3. 打分 `score = count * log(distinct_days+1) * recencyDecay`,取 TopN。
|
||||
4. 生成候选 `Candidate{product, tool, fixed{...}, varFlags[...], score, samples}`。
|
||||
|
||||
输出示例(`dws shortcut suggest --format table`):
|
||||
|
||||
```
|
||||
候选 | 命令建议 | 依据 | 固定参数 | 可变flag
|
||||
#1 | chat +notify-team | 12 次 / 近 3 天 | open_conv=cid_x | text
|
||||
#2 | doc +new-agenda | 7 次 / 近 5 天 | template=agenda | title
|
||||
```
|
||||
|
||||
## 4. 主动提示(nudge)
|
||||
|
||||
- **时机**:命令成功收尾时(root `PersistentPostRunE`),**抽样**触发(如每 N 次调用或每次
|
||||
命中新达标候选时),避免打扰。仅在 TTY 交互态提示;非交互(Agent/管道/`--yes`)**不提示**。
|
||||
- **频控**:同一候选提示过一次被拒后,冷却期内不再提示(记 `~/.dws/shortcuts/.declined`)。
|
||||
- **交互**:
|
||||
```
|
||||
💡 检测到高频操作:你已 12 次向同一会话发消息。
|
||||
沉淀为快捷指令 dws chat +notify-team --text "..." ?
|
||||
[y] 沉淀 [n] 以后再说 [d] 不再提示此项
|
||||
```
|
||||
- y → 走 §5 生成 YAML;命名默认 `+<tool 去下划线的动宾>`,允许用户改名。
|
||||
|
||||
## 5. 自定义 Shortcut:YAML 格式与运行时加载
|
||||
|
||||
### 5.1 YAML schema(与 P1 `Shortcut` 一一对应)
|
||||
|
||||
`~/.dws/shortcuts/chat.notify-team.yaml`:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
service: chat
|
||||
command: "+notify-team"
|
||||
product: chat
|
||||
description: "发消息到 项目群(自动沉淀于 2026-07-08)"
|
||||
risk: write # 默认 read;send 类判定为 write
|
||||
source: auto # auto=沉淀 / manual=手写
|
||||
flags:
|
||||
- name: text
|
||||
type: string
|
||||
required: true
|
||||
desc: 消息内容
|
||||
execute:
|
||||
tool: send_message
|
||||
bind: # 参数绑定:常量 + ${flag} 模板
|
||||
open_conversation_id: "cid_x"
|
||||
text: "${text}"
|
||||
```
|
||||
|
||||
### 5.2 编译与注册
|
||||
|
||||
`userdef.Compile(yaml)` → `shortcut.Shortcut`,其 `Execute` 由 `bind` 生成:
|
||||
遍历 `bind`,`${flag}` 用 `rt.Str(flag)` 填充,常量原样,组装 params 后 `rt.CallMCP(tool, params)`。
|
||||
完全复用 P1 的 runner,不新增执行路径。
|
||||
|
||||
加载时机:`legacy.go` 装配点,在 `builtin.Commands()` 之后追加 `userdef.Commands()`,
|
||||
一起 merge。复用 `internal/plugin/loader.go` 已验证的「扫 `~/.dws/` 目录 + 挂 cobra」模式。
|
||||
|
||||
### 5.3 冲突与优先级
|
||||
|
||||
- 自定义 shortcut 命令名若与内建 shortcut / helper 冲突:**内建优先**,自定义重命名或跳过并告警。
|
||||
- `+` 前缀天然与 helper leaf 区分,冲突面小。
|
||||
|
||||
## 6. 管理命令面
|
||||
|
||||
```
|
||||
dws shortcut list # 列出内建 + 自定义 shortcut
|
||||
dws shortcut suggest [--min N] # 展示高频候选(不写入)
|
||||
dws shortcut add <candidate|--from-last> # 交互式/从最近一次调用沉淀
|
||||
dws shortcut rm <service> <+cmd> # 删除自定义 shortcut
|
||||
dws shortcut stats # usage 统计概览
|
||||
```
|
||||
|
||||
`dws shortcut` 本身作为一个新的顶层 utility 命令注册(对齐 `dws plugin`)。
|
||||
|
||||
## 7. 安全与隐私边界(红线)
|
||||
|
||||
1. **值不入库**:自由文本/正文/凭证一律不记;`const_args` 仅短 ID/枚举,且过 redact 复核。
|
||||
2. **可关可清**:`DWS_USAGE_TRACKING=0` 关闭;`dws shortcut stats --purge` 清空 usage。
|
||||
3. **执行白名单**:自定义 shortcut 的 `execute.tool` 必须解析到合法 MCP server(过
|
||||
`internal/security` endpoint 白名单),禁止指向任意 endpoint。
|
||||
4. **不自动执行**:沉淀只生成命令定义,**绝不**自动发起写操作;写类 shortcut 仍受 P1 的
|
||||
risk 确认约束。
|
||||
5. **知情**:首次开启埋点一次性告知;提示可永久关闭。
|
||||
|
||||
## 8. 实现顺序(P2 分步,便于 loop 推进)
|
||||
|
||||
- P2-1 usage 埋点:`recordingCaller` 装饰器 + `usage.Append` + jsonl 写 + 开关 + 脱敏白名单。
|
||||
- P2-2 `dws shortcut stats` / `list`:先让数据可见,验证埋点质量。
|
||||
- P2-3 miner + `dws shortcut suggest`:离线挖掘与打分。
|
||||
- P2-4 userdef YAML 加载 + `Compile` + 注册 + 冲突处理(打通「手写 YAML 也能用」)。
|
||||
- P2-5 `dws shortcut add`(从候选/最近调用沉淀)。
|
||||
- P2-6 nudge 主动提示(最后做,最谨慎,默认保守频控)。
|
||||
|
||||
## 9. 待决策点(需产品确认)
|
||||
|
||||
1. 埋点默认开还是默认关?→ **修订后:默认关(opt-in)+ 开启后首跑一次性告知**(原设计默认开,反思后改为 opt-in:自主 agent 不应单方面默认开本地遥测)
|
||||
(`DWS_USAGE_TRACKING=0` / 配置项关闭;`dws shortcut stats --purge` 清空)。实现时以此为准。
|
||||
2. `const_args` 允许记录的字段白名单粒度?(保守起步:只记形如 `*_id/*Id/type/status` 的短值)
|
||||
3. nudge 触发频率与渠道?(建议:仅 TTY、命中新候选时、每候选一生仅一次)
|
||||
4. 自定义 shortcut 是否需要跨设备同步?(v1 先本地 `~/.dws/`,同步留待后续)
|
||||
@@ -0,0 +1,127 @@
|
||||
# DWS Shortcut 能力 — 总体规划
|
||||
|
||||
> 目标:为 dws 引入一套 **声明式高保真命令(Shortcut)** 能力,对齐 larksuite/cli 的 `+command`
|
||||
> 体验(如 `lark-cli contact +search-user`),并在此之上做 dws 差异化:**基于用户高频使用场景,
|
||||
> 主动把常用操作沉淀为自定义 shortcut**。
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
dws 当前的命令有三类来源:
|
||||
|
||||
1. **MCP 运行时动态发现** —— `dws mcp <service> <tool> --json '{...}'`,通用但裸、参数需手拼 JSON。
|
||||
2. **`internal/helpers/` 产品命令** —— 手写 cobra 命令,体验好但每个都从零写、缺统一框架。
|
||||
3. **`internal/registry/recipes.yaml`** —— 多步工作流的静态描述。
|
||||
|
||||
痛点:想新增一个「精选、参数友好、带 dry-run/format/身份」的单命令,只能手写 helper,
|
||||
没有统一的声明式框架,重复劳动多、一致性差。larksuite 的 shortcut 框架正好解决这一层。
|
||||
|
||||
## 2. 与 larksuite/cli 的架构差异(关键)
|
||||
|
||||
| 维度 | larksuite/cli | dws-cli |
|
||||
|------|---------------|---------|
|
||||
| 命令来源 | 静态硬编码 Go shortcut | MCP 运行时动态发现 + helpers |
|
||||
| 调用底座 | Lark SDK 直连 API | MCP JSON-RPC(`executor.Runner`) |
|
||||
| 精选命令层 | `shortcuts/`(200+ 声明式) | `internal/helpers/`(手写 cobra) |
|
||||
| 全局 flag | 框架注入 | root 已内建 `--format/--dry-run/--jq/--yes/--fields/--profile` |
|
||||
|
||||
**结论**:不能直接搬代码。移植的是 shortcut 的**声明式设计**,执行底座换成 dws 的
|
||||
`executor.Runner`,全局能力复用 dws 已有的 output/safety/auth。
|
||||
|
||||
## 3. 分期目标
|
||||
|
||||
### P1 — 静态声明式框架(本期,正在做)
|
||||
- 新建独立模块 `internal/shortcut/`(零侵入现有 helpers)。
|
||||
- `types.go`:`Shortcut` / `Flag` / `RuntimeContext` 声明层。
|
||||
- `runner.go`:把 `Shortcut` 编译成 `*cobra.Command`,串起 flag 注册 → 校验 → dry-run →
|
||||
`executor.Runner.Run` → `output.WriteCommandPayload`。
|
||||
- `register.go`:按 service 分组产出命令,在 `internal/app/legacy.go` 装配点 merge 进命令树。
|
||||
- 样板命令 `contact +search-user`:打通 MCP 执行 / format / dry-run / 身份,作为后续命令模板。
|
||||
- 交付判据:`dws contact +search-user --help`、`--dry-run` 正常;`go build` / `go test` 通过。
|
||||
|
||||
### P2 — 高频场景自动沉淀(后续,先设计再实现)
|
||||
- **使用埋点**:命令执行入口记录 `~/.dws/usage.jsonl`(只记参数形状,不记敏感值)。
|
||||
- **模式挖掘**:`dws shortcut suggest` 聚合高频 `(service, tool, 固定参数组合)`。
|
||||
- **主动沉淀**:命中候选时提示用户,一键写入 `~/.dws/shortcuts/*.yaml`(声明式,与 P1 结构对应)。
|
||||
- **运行时加载**:`register.go` 额外扫描 `~/.dws/shortcuts/*.yaml` 动态注册,复用
|
||||
`internal/plugin/loader.go` 已验证的「从 `~/.dws/` 加载并挂 cobra 命令」模式。
|
||||
|
||||
## 4. 落地方式(P1)
|
||||
|
||||
采用**独立模块 + 装配点 merge**,不改 helpers 内部:
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / RuntimeContext
|
||||
runner.go # 声明式→cobra 编译 + 执行管道
|
||||
register.go # Commands(runner) []*cobra.Command,按 service 分组
|
||||
contact/
|
||||
search_user.go # 样板:var SearchUser = shortcut.Shortcut{...}
|
||||
shortcuts.go # Shortcuts() []shortcut.Shortcut
|
||||
```
|
||||
|
||||
接线:`internal/app/legacy.go: newLegacyPublicCommands` 里,
|
||||
`helpers.NewPublicCommands(runner)` 之后追加 `shortcut.Commands(runner)`,
|
||||
一起走 `mergeTopLevelCommands`(同名 service 命令自动合并,`+xxx` 作为其子命令)。
|
||||
|
||||
复用点:
|
||||
- 执行:`executor.NewHelperInvocation` + `runner.Run`(与 helper 完全一致的调用路径)。
|
||||
- 输出:`output.WriteCommandPayload(cmd, resp, output.FormatJSON)`(自动吃 root 的 `--format/--jq/--fields`)。
|
||||
- dry-run:读 root `--dry-run`,置 `Invocation.DryRun`,由 runner 返回请求预览。
|
||||
- 身份/安全:复用 `--profile`、`internal/safety`(高风险 `--yes` 确认)。
|
||||
|
||||
## 5. Shortcut 声明模型(草案)
|
||||
|
||||
```go
|
||||
type Shortcut struct {
|
||||
Service string // "contact" → 顶层命令
|
||||
Command string // "+search-user" → 子命令(保留 + 前缀,对齐 larksuite)
|
||||
Description string
|
||||
Risk string // read | write | high-risk-write
|
||||
Flags []Flag
|
||||
Validate func(*RuntimeContext) error
|
||||
Execute func(*RuntimeContext) error // 必填;内部调 rt.CallMCP(...)
|
||||
}
|
||||
|
||||
type Flag struct {
|
||||
Name, Type, Default, Desc string
|
||||
Required bool
|
||||
Enum []string
|
||||
}
|
||||
```
|
||||
|
||||
`RuntimeContext` 给 Execute 提供:flag 读取(`Str/Bool/Int/StrSlice/Changed`)、
|
||||
`CallMCP(product, tool, params)`(内部 `runner.Run`)、`Output(payload)`、`DryRun()`。
|
||||
|
||||
## 6. 风险与边界
|
||||
|
||||
- **与 `dws mcp` 通道的边界**:shortcut 是「人工精选的薄封装」,不替代通用 MCP 通道;
|
||||
一个 tool 可以既能 `dws mcp` 直调,也能有 shortcut。
|
||||
- **自定义 shortcut 安全(P2)**:YAML 的 `execute` 若允许任意 MCP 调用,需过
|
||||
`internal/security` 的 endpoint 白名单,且沉淀的参数值要脱敏。
|
||||
- **命名冲突**:`+` 前缀天然与现有 leaf 命令区分,降低与 helper 命令的冲突面。
|
||||
- **edition 差异**:oss / enterprise 的可用 service 不同,注册时按 edition 过滤(后续接入)。
|
||||
|
||||
## 7. 进度看板
|
||||
|
||||
- [x] P1-1 types.go — `Shortcut` / `Flag` / `Risk` 声明层
|
||||
- [x] P1-2 runner.go — `RuntimeContext` + `mount` 编译 + 校验/确认/dry-run;`CallMCP` 委托 `helpers.CallMCPToolOnServer`(复用错误分类/输出/dry-run)
|
||||
- [x] P1-3 register.go + `internal/shortcut/contact/search_user.go` + `builtin` 聚合包
|
||||
- [x] P1-4 接线 `legacy.go`(append 到 `mergeTopLevelCommands`)+ build/test 全绿
|
||||
- [ ] P2 设计文档(进行中)
|
||||
|
||||
### P1 落地实证(已验证)
|
||||
|
||||
- `dws contact +search-user --help`:命令挂载,继承全局 `--format/--dry-run/--jq/...`。
|
||||
- 必填校验:不传 `--query` → 结构化 validation 错误。
|
||||
- `--dry-run`:走 helpers 路径输出 `[DRY-RUN]` 预览(tool + 参数)。
|
||||
- 命令树 merge:`+search-user` 与现有 `user/dept/label/relation` 共存,`contact user search` 未受影响。
|
||||
- 测试:`internal/shortcut` 单测通过;`internal/app` 全量回归通过。
|
||||
|
||||
### P1 关键决策记录
|
||||
|
||||
- **执行底座复用 helpers 而非裸 `runner.Run`**:`CallMCP` 委托 `helpers.CallMCPToolOnServer(product, tool, params)`,
|
||||
一步获得错误分类(auth/PAT/业务)+ 格式化输出 + dry-run,避免重造劣质输出层。代价是
|
||||
`internal/shortcut → internal/helpers` 的单向依赖(无环)。后续若要 shortcut 做多调用编排/输出重塑,
|
||||
再补一个返回原始 payload 的 `CallMCPRaw`。
|
||||
- **避免 import 环**:service 包(contact)import 核心 `shortcut` 包并在 `init()` 注册;
|
||||
`builtin` 聚合包 blank-import 各 service 包;`app` 只依赖 `builtin`。
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,168 @@
|
||||
# Shortcut 真实测试跟进清单
|
||||
|
||||
生成时间:`2026-07-15T16:58:39`
|
||||
|
||||
来源:`docs/shortcut-real-read-results.json` 与 `docs/shortcut-real-write-results.json`。
|
||||
|
||||
口径:记录真实后端测试中需要继续定位的 case,用于 CR 和问题分派;Agent 使用入口以公开 shortcut catalog 和产品 skill 为准。
|
||||
|
||||
总计:156 条。
|
||||
|
||||
| # | suite | shortcut | risk | status | category | fixability | 处理依据 |
|
||||
|---:|---|---|---|---|---|---|---|
|
||||
| 1 | read | `aitable +base-get-primary-doc-id` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 2 | read | `aitable +chart-share-get` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 3 | read | `aitable +dashboard-share-get` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 4 | read | `aitable +export-data` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 5 | read | `aitable +record-primary-doc-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 6 | read | `aitable +role-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 7 | read | `aitable +workflow-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 8 | read | `aitable +workflow-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 9 | read | `attendance +get-class` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 10 | read | `attendance +get-global-setting` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 11 | read | `attendance +get-group` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 12 | read | `attendance +get-group-filtered` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 13 | read | `attendance +get-leave-balance` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 14 | read | `attendance +list-report-columns` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 15 | read | `attendance +query-report-leave` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 16 | read | `calendar +find-room` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 17 | read | `calendar +room-find` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 18 | read | `chat +category-list-conversations` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 19 | read | `chat +chat-get-by-id` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 20 | read | `chat +chat-members-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 21 | read | `chat +chat-messages` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 22 | read | `chat +messages-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 23 | read | `chat +messages-resource-url` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 24 | read | `chat +search-msg` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 25 | read | `chat +thread-replies` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 26 | read | `contact +get-roster` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 27 | read | `contact +list-roster-fields` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 28 | read | `devapp +credentials-get` | read | held | held | manual-approval | 高风险或无安全目标,需人工逐项授权后执行。 |
|
||||
| 29 | read | `drive +download` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 30 | read | `drive +list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 31 | read | `minutes +action-items` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 32 | read | `minutes +latest-minutes` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 33 | read | `minutes +minutes-search` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 34 | read | `minutes +transcript` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 35 | read | `oa +done-approvals` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 36 | read | `oa +pending` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 37 | read | `report +report-latest` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 38 | read | `todo +due-today` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 39 | read | `todo +related-tasks` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 40 | read | `wiki +node-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 41 | read | `wiki +resolve-space` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 42 | read | `wiki +space-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 43 | write | `aitable +advperm-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 44 | write | `aitable +advperm-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 45 | write | `aitable +attachment-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 46 | write | `aitable +base-copy` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 47 | write | `aitable +base-delete` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 48 | write | `aitable +base-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 49 | write | `aitable +chart-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 50 | write | `aitable +chart-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 51 | write | `aitable +chart-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 52 | write | `aitable +dashboard-arrange` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 53 | write | `aitable +dashboard-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 54 | write | `aitable +dashboard-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 55 | write | `aitable +dashboard-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 56 | write | `aitable +field-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 57 | write | `aitable +field-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 58 | write | `aitable +form-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 59 | write | `aitable +form-field-hide` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 60 | write | `aitable +form-field-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 61 | write | `aitable +form-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 62 | write | `aitable +form-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 63 | write | `aitable +import-data` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 64 | write | `aitable +import-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 65 | write | `aitable +record-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 66 | write | `aitable +record-primary-doc-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 67 | write | `aitable +record-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 68 | write | `aitable +record-upsert` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 69 | write | `aitable +role-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 70 | write | `aitable +role-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 71 | write | `aitable +role-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 72 | write | `aitable +section-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 73 | write | `aitable +section-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 74 | write | `aitable +section-move-node` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 75 | write | `aitable +section-rename` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 76 | write | `aitable +section-reorder` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 77 | write | `aitable +table-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 78 | write | `aitable +table-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 79 | write | `aitable +view-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 80 | write | `aitable +view-duplicate` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 81 | write | `aitable +view-lock` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 82 | write | `aitable +view-set-fill-color-rule` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 83 | write | `aitable +view-set-frozen-cols` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 84 | write | `aitable +view-set-row-height` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 85 | write | `aitable +view-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 86 | write | `aitable +workflow-disable` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 87 | write | `aitable +workflow-enable` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 88 | write | `attendance +boss-check` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 89 | write | `attendance +create-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 90 | write | `attendance +create-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 91 | write | `attendance +import-schedule` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 92 | write | `attendance +save-leave-balance` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 93 | write | `attendance +update-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 94 | write | `attendance +update-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 95 | write | `attendance +update-group-members` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 96 | write | `attendance +update-leave-type` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 97 | write | `calendar +respond-event` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 98 | write | `chat +category-add-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 99 | write | `chat +category-remove-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 100 | write | `chat +chat-add-bot` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 101 | write | `chat +chat-audit-join` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 102 | write | `chat +chat-mute-member` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 103 | write | `chat +chat-quit` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 104 | write | `chat +chat-remove-bot` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 105 | write | `chat +chat-role-remove` | high-risk-write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 106 | write | `chat +chat-role-remove-user` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 107 | write | `chat +chat-transfer-owner` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 108 | write | `chat +chat-update-icon` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 109 | write | `chat +chat-update-settings` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 110 | write | `chat +conversation-clear-messages` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 111 | write | `chat +conversation-clear-red-point` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 112 | write | `chat +conversation-hide` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 113 | write | `chat +conversation-mark-read` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 114 | write | `chat +conversation-mark-unread` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 115 | write | `chat +conversation-mute` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 116 | write | `chat +conversation-mute-at-all` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 117 | write | `chat +conversation-mute-red-envelope` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 118 | write | `chat +conversation-set-top` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 119 | write | `chat +messages-add-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 120 | write | `chat +messages-add-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 121 | write | `chat +messages-batch-recall-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 122 | write | `chat +messages-batch-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 123 | write | `chat +messages-combine-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 124 | write | `chat +messages-create-text-emotion` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 125 | write | `chat +messages-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 126 | write | `chat +messages-forward-topic` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 127 | write | `chat +messages-recall` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 128 | write | `chat +messages-recall-by-bot` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 129 | write | `chat +messages-remove-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 130 | write | `chat +messages-remove-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 131 | write | `chat +messages-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 132 | write | `chat +messages-send-card` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 133 | write | `chat +messages-set-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 134 | write | `chat +messages-set-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 135 | write | `chat +messages-unset-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 136 | write | `chat +messages-unset-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 137 | write | `devapp +event-subscribe` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 138 | write | `devapp +event-unsubscribe` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 139 | write | `devapp +permission-add` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 140 | write | `devapp +permission-remove` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 141 | write | `devapp +robot-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 142 | write | `devapp +robot-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 143 | write | `devapp +robot-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 144 | write | `devapp +security-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 145 | write | `devapp +version-create` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 146 | write | `devapp +version-publish` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 147 | write | `ding +send-by-message` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 148 | write | `doc +comment-create-inline` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 149 | write | `doc +template-apply` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 150 | write | `minutes +record-pause` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 151 | write | `minutes +record-resume` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 152 | write | `minutes +record-stop` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 153 | write | `oa +approve-by` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 154 | write | `wiki +node-copy` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 155 | write | `wiki +node-move` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 156 | write | `wiki +wiki-new-doc` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DWS Shortcut 完成情况报告</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#161d2c;--ink:#e6edf6;--muted:#93a1b5;--line:#26314a;
|
||||
--blue:#4f9cff;--green:#3fb950;--yellow:#d5a429;--red:#f25c5c;--accent:#6ea8fe;--purple:#a371f7}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:linear-gradient(180deg,#0d1220,#0f1420);color:var(--ink);
|
||||
font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:0 0 80px}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
|
||||
header{padding:52px 22px 28px;text-align:center;border-bottom:1px solid var(--line);
|
||||
background:radial-gradient(1200px 300px at 50% -60px,rgba(79,156,255,.16),transparent)}
|
||||
h1{font-size:29px;margin:0 0 8px;letter-spacing:.5px}
|
||||
.sub{color:var(--muted);font-size:14px}
|
||||
.stats{display:flex;gap:13px;justify-content:center;flex-wrap:wrap;margin:26px 0 4px}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 20px;min-width:118px}
|
||||
.stat .n{font-size:27px;font-weight:700;color:var(--accent)}
|
||||
.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
|
||||
h2 .ico{color:var(--accent);margin-right:8px}
|
||||
h3{font-size:15.5px;margin:22px 0 9px;color:var(--accent)}
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{background:#1b2536;color:var(--muted);font-weight:600;font-size:12.5px}
|
||||
tr:last-child td{border-bottom:none}
|
||||
td.c,th.c{text-align:center}
|
||||
.num{color:var(--accent);font-weight:700;text-align:center}
|
||||
.g{color:var(--green);font-weight:700}.s{color:var(--yellow);font-weight:700}.b{color:var(--red);font-weight:700}
|
||||
.ok{color:var(--green)}.star{color:var(--yellow)}
|
||||
code{background:#0c1120;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#cfe0ff}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 18px;margin:13px 0}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.layer{border-radius:12px;padding:16px 18px}
|
||||
.l-wrap{background:linear-gradient(180deg,rgba(79,156,255,.08),transparent);border:1px solid #234b6b}
|
||||
.l-smart{background:linear-gradient(180deg,rgba(163,113,247,.10),transparent);border:1px solid #4a3a6b}
|
||||
.layer h3{margin-top:0}
|
||||
.pill{display:inline-block;background:#12283a;color:#7fc6ff;border:1px solid #234b6b;border-radius:6px;padding:1px 7px;font-size:12px;margin:2px 3px 2px 0}
|
||||
.flow{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
|
||||
.flow b{color:var(--ink)}.flow .arw{color:var(--purple)}
|
||||
.concl{background:linear-gradient(90deg,rgba(63,185,80,.10),transparent);border-left:3px solid var(--green);padding:14px 18px;border-radius:8px;margin-top:16px}
|
||||
.keyfind{background:linear-gradient(90deg,rgba(213,164,41,.10),transparent);border-left:3px solid var(--yellow);padding:14px 18px;border-radius:8px;margin:14px 0}
|
||||
footer{color:var(--muted);text-align:center;font-size:12.5px;margin-top:40px}
|
||||
a{color:var(--accent)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>DWS Shortcut 完成情况报告</h1>
|
||||
<div class="sub">对齐基准 larksuite/cli · 执行底座 钉钉 MCP · 随 loop 持续更新</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n">366</div><div class="l">shortcut 总数</div></div>
|
||||
<div class="stat"><div class="n">298</div><div class="l">1:1 封装层</div></div>
|
||||
<div class="stat"><div class="n">68</div><div class="l">真·智能层</div></div>
|
||||
<div class="stat"><div class="n">16</div><div class="l">覆盖服务</div></div>
|
||||
<div class="stat"><div class="n">0</div><div class="l">失败/panic/编造</div></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
|
||||
<h2><span class="ico">①</span>做了什么:两个层次</h2>
|
||||
<p>诚实区分——shortcut 分两层,价值定位不同,不混为一谈。</p>
|
||||
<div class="two">
|
||||
<div class="layer l-wrap">
|
||||
<h3>🔵 1:1 封装层 · 298 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">一个 shortcut ≡ 一个 MCP tool。把裸 <code>dws mcp <svc> <tool> --json '{…}'</code> 收敛成命名 flag,附校验/风险确认/Intent。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:DX 与 AI-agent 可发现性,<b>不是新能力</b>。</div>
|
||||
<div><span class="pill">命名 flag</span><span class="pill">required/enum 校验</span><span class="pill">风险确认</span><span class="pill">自然语言 Intent</span><span class="pill">dry-run/format</span></div>
|
||||
</div>
|
||||
<div class="layer l-smart">
|
||||
<h3>🟣 真·智能层 · 68 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">照 lark-cli 范式的多步/编排/智能,<b>不是 1:1</b>。框架新增 <code>CallMCPData</code>(多步取数,对标 lark <code>CallAPITyped</code>)+ <code>resolveUser</code>(名→ID,对标 <code>ResolveOpenIDsTyped</code>)。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:<b>这才是「shortcut 作为新能力」</b>。</div>
|
||||
<div><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">按名解析+消歧</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">多工具编排</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">失败回滚</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">跨服务</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">②</span>真·智能层 68 条明细(节选)</h2>
|
||||
<table>
|
||||
<thead><tr><th>shortcut</th><th>多步/智能逻辑</th><th class="c">验证</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>chat +dm --to <名></code></td><td>搜人→解析 userId→发单聊;多人消歧</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +lookup --name <名></code></td><td>搜人→解析→取完整资料</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>todo +assign --to <名></code></td><td>解析人→建待办并设执行人</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +org --name <名></code></td><td>解析人→取 deptId→查部门详情(3 步)</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>contact +team --name <名></code></td><td>解析人→取部门→列部门成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +free --who <名></code></td><td>解析人→查其时段忙闲</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +book [--with <名CSV>]</code></td><td>建日程→按名加参与者→<b>失败回滚删日程</b></td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>calendar +invite --event --with</code></td><td>解析多人→加入已有日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +suggest-time --with</code></td><td>解析多人→推荐可开会时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +today</code></td><td>算今天范围→列我今天日程</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +next-event</code></td><td>近 7 天→取最近一个日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +reschedule --event</code></td><td>查日程详情→改时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +send-to-group --group <群名></code></td><td>按群名搜群→消歧→发消息</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +group-members --group <群名></code></td><td>搜群→列群成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +broadcast --to <名CSV></code></td><td>多名逐一解析→群发单聊,失败汇总</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +todo-done --task <关键词></code></td><td>列我待办→按标题匹配→标完成</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +remind --task --at</code></td><td>给自己建带提醒的待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card">
|
||||
<b>质量亮点(agent 严守 ground truth)</b>:<code>+send-to-group</code> 纠正了「按群名搜群」的正确工具(<code>search_groups</code> 而非按成员昵称的 <code>search_common_groups</code>);<code>+wiki-new-doc</code> 发现 <code>create_file</code> 在 doc server 并正确跨服务路由;<code>+mail-to</code> 因钉钉无 email 字段<b>主动 skip 拒绝编造</b>。
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">③</span>测试验证(零副作用全量)</h2>
|
||||
<p>写/删命令不能真跑,用<b>假 Caller 拦截</b>——每条命令走完「解析→校验→确认→组装 MCP 调用」,捕获组装出的 <code>(product,tool,params)</code>,不真发网络。</p>
|
||||
<table>
|
||||
<thead><tr><th>验证项</th><th>范围</th><th>结果</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>go build ./...</code> / gofmt / vet</td><td>全仓</td><td class="ok">✅ 0 告警</td></tr>
|
||||
<tr><td>TestAllShortcutsAssemble</td><td>全部 366</td><td class="ok">✅ 322 组装真实MCP · 44 自校验 · 0 失败/panic</td></tr>
|
||||
<tr><td>TestAllToolLiteralsAreReal</td><td>tool 字面量</td><td class="ok">✅ 0 编造(比对 helper ground truth)</td></tr>
|
||||
<tr><td>TestAllHaveIntent</td><td>全部 366</td><td class="ok">✅ 每条均有自然语言描述</td></tr>
|
||||
<tr><td>TestNoDuplicateCommands</td><td>全部</td><td class="ok">✅ 无重复 · 命名规范</td></tr>
|
||||
<tr><td>usage / userdef 单测</td><td>埋点/沉淀</td><td class="ok">✅ 全通过</td></tr>
|
||||
<tr><td>app 包全量回归</td><td>internal/app</td><td class="ok">✅ ~72s 通过(未破坏现有命令)</td></tr>
|
||||
<tr><td>智能层真机验证(9 批)</td><td>只读/解析类 20+ 条</td><td class="ok">✅ 端到端返回真实数据、投影正确</td></tr>
|
||||
<tr><td>真机抓修真实 bug</td><td>合成测试盖不住的投影/解析偏差</td><td class="ok">✅ 修复 8 处(resolve-dept/at-me/group-members/free/today/suggest-time/unread-chats/dept-members)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:var(--muted);font-size:13px">真机验证补齐了 assemble 测试的盲区:合成响应验证不了「防御式投影是否匹配真实响应结构」。9 批真机验证抓到并修复 8 处解析/投影偏差(如 <code>+resolve-dept</code> 漏了真实容器 key <code>deptList</code>、<code>+at-me</code> 未拍平嵌套、<code>+today/+free</code> 直吐冗长 raw)。少数命令(chat 会话消息读、minutes)因 org/PAT 权限受限无法真机跑通,组装链路仍由 assemble 测试覆盖。</p>
|
||||
|
||||
<h2><span class="ico">④</span>效果评估 GSB(vs lark-cli)</h2>
|
||||
<h3>4.1 能力覆盖 GSB(按 dws 实际暴露的 MCP tool 数 = helper∪shortcut,非 shortcut 数)</h3>
|
||||
<table>
|
||||
<thead><tr><th>lark 服务</th><th class="c">lark</th><th>dws</th><th class="c">dws</th><th class="c">GSB</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>im</td><td class="c">21</td><td>chat</td><td class="c">95</td><td class="c g">G</td><td>群/消息/机器人更全</td></tr>
|
||||
<tr><td>mail</td><td class="c">21</td><td>mail</td><td class="c">43</td><td class="c g">G</td><td>覆盖更广</td></tr>
|
||||
<tr><td>doc</td><td class="c">14</td><td>doc</td><td class="c">34</td><td class="c g">G</td><td>块级读写更细</td></tr>
|
||||
<tr><td>minutes</td><td class="c">14</td><td>minutes</td><td class="c">25</td><td class="c g">G</td><td>录音/说话人更全</td></tr>
|
||||
<tr><td>calendar</td><td class="c">12</td><td>calendar</td><td class="c">24</td><td class="c g">G</td><td>会议室/ACL 更全</td></tr>
|
||||
<tr><td>contact</td><td class="c">2</td><td>contact</td><td class="c">15</td><td class="c g">G</td><td>部门/角色/花名册更全</td></tr>
|
||||
<tr><td>wiki</td><td class="c">12</td><td>wiki</td><td class="c">16</td><td class="c g">G</td><td>略优</td></tr>
|
||||
<tr><td>base</td><td class="c">87</td><td>aitable</td><td class="c">79</td><td class="c s">S</td><td>持平;helper 仅 16,<b>shortcut 补齐 +63</b>(真 gap-fill)</td></tr>
|
||||
<tr><td>task</td><td class="c">18</td><td>todo</td><td class="c">20</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>drive</td><td class="c">26</td><td>drive</td><td class="c">25</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>apps</td><td class="c">63</td><td>devapp</td><td class="c">25</td><td class="c b">B</td><td><b>helper 无 devapp,25 全由 shortcut 补</b>,但仍少于 lark</td></tr>
|
||||
<tr><td>sheets</td><td class="c">84</td><td>sheet</td><td class="c">60</td><td class="c b">B</td><td>钉钉表格 MCP 较少;helper 已覆盖</td></tr>
|
||||
<tr><td>vc/okr/slides/markdown/whiteboard/note/event</td><td class="c">62</td><td>—</td><td class="c">0</td><td class="c b">B</td><td>钉钉无对应能力,<b>客观不可对齐</b></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px;margin:6px 0 2px">🟢 G 7 领域 · 🟡 S 3 领域 · 🔴 B(apps/sheets 少于 lark + 6 领域钉钉无能力)。base 的"持平"几乎全靠 shortcut gap-fill(helper 仅 16)。dws 独有:oa/attendance/report/ding/aisearch/live/devdoc。</div>
|
||||
|
||||
<h3>4.2 组合/智能层 GSB(关键发现)</h3>
|
||||
<div class="keyfind">
|
||||
<b>关键结论</b>:lark 有 ~104 个组合(≥2 次 API)shortcut,但 <b>lark 的组合性多源于飞书 REST API 太细粒度</b>(要先查 spreadsheetToken→sheetId→再操作);<b>钉钉 MCP 是粗粒度的——一个 tool = 一个完整操作</b>,所以 lark 的组合在钉钉这边<b>大量塌缩成 1:1</b>(已被封装层覆盖),或<b>根本没有对应 tool</b>。
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>lark 组合来源</th><th class="c">数量</th><th class="c">GSB</th><th>钉钉现实</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>sheets(先解析 sheetId)</td><td class="c">41</td><td class="c s">S</td><td>钉钉直接吃 token → 1:1 层已覆盖</td></tr>
|
||||
<tr><td>apps db-env/audit/log/trace</td><td class="c">17</td><td class="c b">B</td><td>钉钉无对应工具</td></tr>
|
||||
<tr><td>drive/doc/im(上传/媒体/搜索)</td><td class="c">23</td><td class="c s">S</td><td>多为钉钉 1:1 已覆盖</td></tr>
|
||||
<tr><td>calendar/contact/wiki/minutes/todo 编排</td><td class="c">~10</td><td class="c g">G</td><td>✅ 已建为真·智能 shortcut(+book/+lookup/+org/+wiki-new-doc/+reschedule…)</td></tr>
|
||||
<tr><td>okr/whiteboard/slides/vc</td><td class="c">11</td><td class="c b">B</td><td>钉钉无对应能力</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px">→ 钉钉真正需要「组合」的场景(按名解析+多工具编排+跨服务),dws 已覆盖并<b>额外做了 lark 没有的</b>(+today/+broadcast/+share-doc/+action-items 等)。<b>不盲目复刻 lark 的机械多步</b>(在钉钉会成冗余假组合)。</div>
|
||||
|
||||
<h2><span class="ico">⑤</span>dws 差异化优势</h2>
|
||||
<div class="card"><b>复用生产级 MCP 通道</b>(架构性)—— <code>CallMCP</code> 统一继承错误分类(auth/PAT/业务)、dry-run、<code>--format/--jq/--fields</code>;lark 每命令各自实现。</div>
|
||||
<div class="card"><b>协作能力覆盖更全</b> —— chat 95/mail 43/doc 34/minutes 25(dws tool 覆盖)是 lark 对应 2–4 倍。</div>
|
||||
<div class="card"><b>钉钉原生特有能力</b>(lark 完全没有)—— 审批/日志/考勤/DING/企业智能搜索,75 条差异化封装。</div>
|
||||
<div class="card"><b>真·智能编排(22 条)</b> —— 按名解析+消歧、失败回滚、跨服务;<code>resolveUser</code>/<code>CallMCPData</code> 让新智能 shortcut 越写越快。</div>
|
||||
<div class="card"><b>高频自动沉淀(P2 · lark 无此设计)</b>
|
||||
<div class="flow" style="margin-top:9px"><b>高频使用</b><span class="arw">→</span><b>埋点</b><span class="arw">→</span><b>suggest</b><span class="arw">→</span><b>add 写 YAML</b><span class="arw">→</span><b>运行时加载可用</b></div>
|
||||
</div>
|
||||
<div class="card"><b>工程质量</b> —— 假 Caller 拦截,366 条(含写/删)零副作用全量验证,可复跑回归。</div>
|
||||
|
||||
<div class="concl">
|
||||
<b>一句话结论</b>:dws 已把钉钉侧<b>能对齐的都对齐</b>(366 条 = 298 封装 + 68 智能 / 16 服务,1:1 层已去 213 条纯重复),即时协作显著优于 lark,拥有审批/考勤/DING 等原生差异化能力与「高频自动沉淀」独有闭环。lark 的组合优势多因飞书 API 细粒度、在钉钉粗粒度 MCP 下塌缩为 1:1(已覆盖),真正需编排的钉钉侧已建齐;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量验证。
|
||||
</div>
|
||||
|
||||
<footer>DWS Shortcut Report · 由持续精进 loop 维护 · 另见 <a href="shortcut-comparison.html">逐条三方对照 HTML</a> · <a href="shortcut-report.md">Markdown 版</a></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
# DWS Shortcut 能力整合与对齐报告
|
||||
|
||||
> 版本:截至本轮 loop | 对齐基准:larksuite/cli(lark-cli) | 执行底座:钉钉 MCP
|
||||
> 相关文档:[总规划](shortcut-plan.md) · [P2 自动沉淀设计](shortcut-p2-design.md) · [HTML 报告](shortcut-report.html) · [**逐条三方对照 HTML**](shortcut-comparison.html)(每个 shortcut:dws +命令 vs lark-cli vs 原生 MCP 组合)
|
||||
|
||||
---
|
||||
|
||||
## 1. Shortcut 整合了哪些能力
|
||||
|
||||
`dws` 现内建 **298 个 1:1 封装 shortcut** + **68 条 smart 智能编排命令**(§1.3)= **合计 366 条**,覆盖 **16 个钉钉服务**,以 `dws <service> +<command>` 形式提供。
|
||||
|
||||
> ⚠️ **重要修订(去冗余)**:1:1 层原为 511 条,**复盘发现 `internal/helpers/` 早已把大量 MCP tool 封装成 `dws <svc> <verb>` 产品命令**——其中 **213 条 1:1 shortcut 只是把已被 helper 封装过的同一个 tool 用 `+` 前缀又封了一遍、且无输出投影增量,属纯重复**,已删除。**保留的 298 条 = 233 条填 helper 空白(helper 从没封装的 tool)+ 65 条虽 tool 重复但加了干净投影**。这是对"建 1:1 层前没先摸清 helper 已封装什么"的纠偏(详见 §5 复盘)。aisearch/live/devdoc 三个服务的 shortcut 全属纯重复、已整包移除(其 `dws <svc>` 命令仍由 helper 层提供)。
|
||||
|
||||
### 1.1 能力清单(按服务,prune 后)
|
||||
|
||||
| 服务 | 1:1 shortcut 数 | 覆盖能力(摘要) |
|
||||
|------|:---:|------|
|
||||
| chat(群聊/消息) | 79 | 群管理、群成员、群身份角色、消息收发/撤回/转发/表情/卡片、会话置顶/免打扰、消息分组、机器人 |
|
||||
| aitable(多维表 base) | 77 | 数据表/字段/记录/视图/表单/仪表盘/图表/角色/协作全生命周期 |
|
||||
| attendance(考勤)★ | 33 | 打卡记录、审批、排班、班次、考勤组、统计报表、请假 |
|
||||
| devapp(开放平台应用 apps) | 30 | 应用增删改查、成员、权限、版本发布、事件订阅、扩展机器人/H5 配置 |
|
||||
| doc(文档) | 16 | 文档/文件夹、正文块读写、权限、附件、节点 |
|
||||
| contact(通讯录) | 9 | 用户/部门搜索与详情、角色、花名册 |
|
||||
| drive(钉盘) | 8 | 文件/文件夹管理、下载、复制移动、权限、最近访问 |
|
||||
| calendar(日历) | 8 | 日程、参与人、会议室、忙闲、ACL、日历本 |
|
||||
| minutes(AI 听记) | 7 | 妙记详情/逐字稿、录音控制、说话人 |
|
||||
| oa(审批)★ | 6 | 审批实例、单据处理、模板、流程 |
|
||||
| mail(邮箱) | 6 | 邮件搜索/线程、标签、联系人、收信规则(投影类保留) |
|
||||
| wiki(知识库) | 5 | 知识空间、节点、成员 |
|
||||
| todo(待办 task) | 5 | 待办、子任务、执行人/参与人、附件 |
|
||||
| ding(DING)★ | 5 | 机器人/个人 DING 发送、撤回、接收状态 |
|
||||
| sheet(钉钉表格) | 2 | 区域读写(投影类保留) |
|
||||
| report(日志)★ | 2 | 日志收件箱/发件箱 |
|
||||
| **合计** | **298** | **16 个服务** |
|
||||
|
||||
★ = 钉钉特有服务,lark-cli 无对应(详见 §3 GSB)。**注**:多数服务的 `shortcut 数` 已远小于该服务的 MCP tool 总数——因为 tool 的基础封装由 helper 层的 `dws <svc> <verb>` 命令承担,1:1 shortcut 只保留 helper 没覆盖的、或加了投影的。
|
||||
|
||||
### 1.2 每个 shortcut 统一具备的能力(框架注入)
|
||||
|
||||
不是简单命令别名,而是叠加在裸 MCP 之上的**精选薄封装**,统一获得:
|
||||
|
||||
- **声明式定义**:`Shortcut{Service, Command, Product, Risk, Flags, Execute}`,一处声明、框架编译成 cobra 命令。
|
||||
- **自然语言 Intent**:每条 shortcut 均带一段自然语言描述(做什么/何时用/关键输入产出,写删类点明副作用),面向用户与 AI agent 的意图匹配;`--help` 展示为长描述,`dws shortcut list` 输出 `intent` 字段。全部 366 条覆盖(`TestAllHaveIntent` 强制校验)。
|
||||
- **参数收敛**:把裸 `dws mcp <svc> <tool> --json '{...}'` 的手拼 JSON,收敛成命名 flag(`--query`/`--group`…)。
|
||||
- **内建校验**:required / enum 声明式校验,结构化错误提示。
|
||||
- **风险确认**:read / write / high-risk-write 分级,写/删操作 `--yes` 前二次确认。
|
||||
- **复用生产级 MCP 通道**:错误分类(auth/PAT/业务)、`--dry-run` 预览、`--format`/`--jq`/`--fields` 输出,全部免费继承(详见 §4)。
|
||||
|
||||
### 1.3 两个层次:1:1 封装层 vs 真·多步/智能层(重要澄清)
|
||||
|
||||
诚实区分——上面 298 条**绝大多数是 1 shortcut ≡ 1 个 MCP tool 的 1:1 封装**,本质是「给 MCP 套命名 flag + 校验 + Intent 的友好外壳」,价值在 DX 与 agent 可发现性,**不是新能力**。
|
||||
|
||||
真正的「shortcut 作为新能力」是 `internal/shortcut/smart/` 下的**多步/智能** shortcut——照 larksuite/cli 的实现范式(`CallAPITyped` 链式多步、按名解析 ID、Validate、DryRun 计划、失败回滚)落地。框架为此新增 `RuntimeContext.CallMCPData(product, tool, params)`(对应 lark 的 `CallAPITyped`:调用并返回 data 供下一步,跨服务)。
|
||||
|
||||
已落地的真·智能 shortcut(`internal/shortcut/smart/`,共 68 条,下表为代表性节选):
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
| `calendar +suggest-time --with <姓名CSV>` | 解析多人→推荐可开会时间 | ✅ 编译/挂载 |
|
||||
| `calendar +invite --event <id> --with <姓名CSV>` | 解析多人→加入已有日程 | ✅ 编译/挂载 |
|
||||
| `doc +share-doc --to <姓名> --url` | 解析人→把文档链接私信 TA | ✅ 编译/挂载 |
|
||||
| `calendar +today` | 算出今天时间范围→列我今天的日程 | ✅ **真机端到端**(返回真实日程+参会人) |
|
||||
| `calendar +next-event` | 近 7 天日程→按时间取最近一个 | ✅ 编译/挂载 |
|
||||
| `contact +team --name <姓名>` | 解析人→取部门→列部门直接成员 | ✅ 编译/挂载 |
|
||||
| `todo +remind --task --at` | 给自己建带截止/提醒时间的待办 | ✅ 编译/挂载 |
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
| `oa +approve-by --keyword` ★ | 列待审批→匹配→取 taskId→通过(钉钉原生,lark 无) | ✅ 编译/挂载 |
|
||||
| `attendance +my-attendance` ★ | 当前用户→算今天→查我打卡(路由 attendance-wukong server) | ✅ 编译/挂载 |
|
||||
| `todo +overdue` | 列我待办→本地过滤过期→投影输出 | ✅ **真机端到端** |
|
||||
| `report +report-latest` ★ | 列我日志→取最新→取详情 | ✅ 编译/挂载 |
|
||||
| `aitable +find-record --base --table` | 表内按关键词查记录 | ✅ 编译/挂载 |
|
||||
|
||||
另有 gap-buildable 补齐(批6):`chat +my-groups`(列群+类型过滤+投影)、`calendar +find-room`(时段找可用会议室)、`minutes +minutes-search`(关键词搜妙记)、`mail +search-mail`(搜邮件+自动解析绑定邮箱)、`drive +find-file`(搜钉盘文件+投影)。
|
||||
|
||||
批7-8 续补(10 条):`chat +at-me`(近期@我)、`calendar +cancel-event`(查→删,高危二次确认)、`todo +assign-multi`(多人指派)、`contact +dept-members`(搜部门→列成员)、`minutes +transcript`(最新妙记逐字稿)、`calendar +week`(本周日程)、`contact +by-mobile`(手机号→资料)、`todo +created-todos`(我创建的)、`chat +unread-chats`(未读会话)、`mail +unread-mail`(未读邮件)。
|
||||
|
||||
批9 续补(3 条·手工,对齐 gap-buildable):`minutes +detail`(单命令聚合一条听记的 basic/summary/keywords/transcript/todos,partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合,补齐一次一组的 1:1 `+word-replace`)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片(≤20/批)+跨 `aitable-helper` server fanout+合并,补齐单批 20 条上限)。
|
||||
|
||||
批10 续补(3 条·多 agent 并行,对齐 gap-buildable):`chat +thread-replies`(拉某条话题消息的全部回复 list_topic_replies + sender/text/time 投影)、`todo +related-tasks`(creator+executor+participant 三角色并集「与我相关的待办」+ taskId 去重 + 投影)、`doc +find-doc`(按关键词搜云文档 search_documents + title/url/type/token 投影)。
|
||||
|
||||
批11 续补(3 条·多 agent 并行):`aitable +resolve-base`(按名搜 Base 解析 baseId,0/1/多候选消歧 search_bases)、`chat +chat-messages`(群/单聊会话消息列表,list_conversation_message_v2 / list_individual_chat_message 互斥+投影)、`mail +find-mail-user`(按名/邮箱搜企业邮箱联系人 search_mail_users + 投影)。
|
||||
|
||||
批12 续补(3 条·多 agent 并行,dws 原生 resolver 层,按名解析 ID):`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内名→tableId,本地匹配)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,含数值 ID 兼容)。均 0/1/多候选消歧,对标 `resolveUser` 的各资源版。
|
||||
|
||||
批28 续补(3 条·净新增便利读,dws 原生):`oa +pending`(**只读**列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(今天到期待办,planFinishDate 服务端过滤,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。均只读、真机验证(+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper)。
|
||||
|
||||
批29 续补(3 条·净新增便利读,dws 原生):`oa +done-approvals`(我已处理的审批历史 get_done_tasks;真机抓到并修复 pageSize=0 → 默认 20 的后端报错 bug)、`mail +recent-mail`(近期收件箱会话 list_mailbox_threads + 解析绑定邮箱/收件箱)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance 自身解析)。真机:+this-month 返回有效空、+done-approvals 修后走空路径、+recent-mail 正确报未绑定邮箱。
|
||||
|
||||
批30 续补(1 条·净新增便利读):`contact +me`(当前用户 get_current_user_profile + 投影 {name,userId,mobile,dept,org,email},agent 的「我是谁」;区别于 1:1 +get-self 吐冗长 raw,真机验证 董鑫阳/202397/模型算法)。
|
||||
|
||||
批31 续补(1 条·净新增便利读):`calendar +my-free`(我自己的忙闲,自动解析当前 userId,默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名,真机验证返回今日忙碌时段)。
|
||||
|
||||
批32 续补(1 条·净新增 dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两重叠检测,默认今天/--in-days;真机验证抓到今日 2 处真实冲突)。这类纯 MCP-tool 的本地编排是复杂写死胡同之外仍有价值的方向。
|
||||
|
||||
批33 续补(1 条·净新增 dws 原生编排,+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段"什么时候能安排会",list_calendar_events + 合并忙碌区间 + 工作窗口内求补集,默认今天 09:00-18:00/--from/--to/--in-days;真机验证今日 4 段空档)。
|
||||
|
||||
共 **68 条真·智能 shortcut**(多批多 agent 工作流并行生成 + 手工续补)。★=钉钉原生编排,lark 完全没有。
|
||||
|
||||
**真机验证(登录态抽样,返回真实数据)**:`calendar +today/+week`(真实日程+投影)、`contact +org`(3 步→部门详情)、`contact +lookup/+free`、`todo +overdue`、`attendance +my-attendance` 等端到端可用。
|
||||
|
||||
### 深度对齐矩阵(逐条分析 lark 361 条 shortcut)
|
||||
|
||||
见 [`shortcut-lark-alignment.md`](shortcut-lark-alignment.md)——12 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉:
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 (40%) | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 (35%) | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42 (12%)** | 钉钉有工具、值得补成智能 shortcut(建设目标) |
|
||||
| covered-smart | 48 (13%) | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
**框架系统性能力已对齐 lark**:`resolveUser`(名→ID)· `CallMCPData`(多步取数)· `rt.Output`(输出投影)· `rt.MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll`(跨字段校验)。
|
||||
|
||||
### 保真度升级(对齐 lark 96% 的输出投影)
|
||||
|
||||
lark 96% 的 shortcut 都做**输出投影**(把原始 API 返回精简为干净字段列表)。已给 **~60 条列表/读类封装**升级到此保真度——从 `rt.CallMCP`(打印原始 MCP 返回)改为 `rt.CallMCPData` + 防御式投影 + `rt.Output`(自动吃 `--format/--jq/--fields`):
|
||||
|
||||
`contact +search-user/+search-mobile/+list-roles/+list-sub-depts` · `todo +get-my-tasks/+list-sub` · `calendar +book-list/+attendee-list` · `drive +list` · `wiki +node-list` · `chat +conversation-list/+category-list/+messages-list-unread-conversations/+messages-list-pin` · `doc +search/+list` · `mail +tag-list/+contact-list` · `aitable +base-list/+base-search` · `oa …`
|
||||
|
||||
示例:`contact +search-user` 由原始 MCP 返回 → 干净 `{count, users:[{name,userId,flowerName,openDingTalkId,title}]}`(真机验证)。这是把封装层往 lark 高保真水平系统性拉升的开始。另有 `mail +to`(按名发邮件)被 agent **正确 skip**——钉钉 contact 无 email 字段、mail `send_email` 需发件人邮箱 `from` 无法解析,宁缺勿错不编造。关键复用:「按名解析人」抽成共享 helper `resolveUser`(对标 lark `ResolveOpenIDsTyped`,带 0/多人消歧,不瞎猜);多步靠 `CallMCPData`(对标 lark `CallAPITyped`)。其中 5 条由多 agent 工作流并行生成——各自以 helper 为 ground truth 研究参数、`send-to-group` 的 agent 还主动纠正了「按群名搜群」的正确工具(`search_groups` 而非按成员昵称的 `search_common_groups`)。
|
||||
|
||||
> 定位:1:1 层是「MCP 友好外壳」,smart 层才是「真 shortcut」。二者不混淆。
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试验证报告
|
||||
|
||||
**验证理念**:写/删命令不能真跑(会发消息、解散群、删数据),故用**假 Caller 拦截**——让每条命令(含写/删)真实走完「解析→校验→确认→组装 MCP 调用」全流程,捕获组装出的 `(product, tool, params)`,只是不真发网络。以此对**全部 366 条**做零副作用验证。
|
||||
|
||||
### 2.1 结果总览(全绿)
|
||||
|
||||
| 验证项 | 范围 | 结果 |
|
||||
|------|------|------|
|
||||
| `go build ./...` | 全仓 | ✅ 通过 |
|
||||
| `gofmt -l` / `go vet` | shortcut 全包 | ✅ 0 未格式化 / 0 告警 |
|
||||
| 框架单元测试(5) | 类型/挂载/校验/分组 | ✅ 全通过 |
|
||||
| **TestAllShortcutsAssemble** | **全部 366 条** | ✅ 322 组装真实 MCP · 44 自校验拦截 · **0 失败 · 0 panic** |
|
||||
| **TestAllToolLiteralsAreReal** | 全部 tool 字面量(逐条) | ✅ **0 编造**(tool 名逐一比对 helper ground truth) |
|
||||
| TestNoDuplicateCommands | 全部 366 条 | ✅ 无重复、命名规范(均 `+` 前缀) |
|
||||
| **TestAllHaveIntent** | 全部 366 条 | ✅ 每条均有自然语言 Intent 描述(无一遗漏) |
|
||||
| usage 包单测(4) | 埋点/脱敏/聚合/开关 | ✅ 全通过 |
|
||||
| app 包全量回归 | `internal/app` | ✅ 72.2s 通过(接线未破坏任何现有命令) |
|
||||
| 只读命令真机验证 | ~25 条(登录态,见 §2.4) | ✅ `contact +me`/`+org`/`+lookup`、`calendar +today/+week/+free/+conflicts/+free-slots`、`doc +find-doc`、`aitable +resolve-base/+resolve-table`、`drive +find-file`、`oa +my-initiated` 等端到端返回真实数据、投影核对 |
|
||||
|
||||
### 2.2 关键指标解读
|
||||
|
||||
- **322 「组装真实 MCP」**:喂合成参数后成功组装出 MCP 调用,且 tool 名经 helper ground truth 核验真实、非编造。
|
||||
- **44 「自校验拦截」**:这些命令有结构化/JSON/互斥输入(如多维表建记录需 JSON、DING 三选一接收人),dummy 值被其**自身校验正确拒绝**——证明校验链路健全。其 tool 名由静态测试 `TestAllToolLiteralsAreReal` 单独覆盖,无遗漏。
|
||||
- **0 编造 / 0 panic / 0 失败**:无幻觉工具名,无运行时崩溃,无死命令。
|
||||
|
||||
> ⚠️ **验证强度分层(诚实口径,勿把"全绿"读成"真机全对")**:`TestAllShortcutsAssemble` 的"0 失败"只证明**能正确组装 MCP 调用、零副作用**——它用**合成响应**,**验证不了防御式投影是否匹配真实响应结构**(真机验证正是靠这个抓到过 deptList 容器、pageSize=0 等 assemble 盖不住的 bug,见 §2.4)。按真机验证强度分三层:**(A) 真机正向验证** ~25 条只读/解析类(返回真实数据、投影核对);**(B) 仅 assemble + 复用已验证 helper**(如 +due-today/+this-month 等,逻辑同构于已验证命令,但该条本身未在真机跑出正样本);**(C) 未对真实后端跑过**——17 条写类 smart(不宜真跑,会发消息/建数据)、6 条 minutes smart(该 org 未开 CLI 数据访问)、mail +recent-mail(无绑定邮箱)。(C) 类**很可能仍有 assemble 盖不住的投影/参数 bug**,不应因"全绿"就当作"真机可用"。
|
||||
|
||||
### 2.3 防幻觉机制(工作流生成时)
|
||||
|
||||
生成阶段每个服务由独立 agent 负责,硬性规则:tool 名与参数 key **只能逐字取自 dws helper 的真实调用点**,无法确定参数的 tool 主动跳过并记录原因(如嵌套对象、时间戳转换、本地文件分片上传)。测试阶段再用 ground truth 二次核验,双重保险。
|
||||
|
||||
### 2.4 真机验证战役(登录态打真实钉钉后端)
|
||||
|
||||
assemble 测试用**合成响应**,能验证「调用是否组装正确」,但验证不了「防御式投影解析是否匹配真实响应结构」。为此做了 9 批真机验证(登录态 corp「钉钉」,token 有效期内),把只读/解析类 smart shortcut 打真实后端、逐条核对投影输出。
|
||||
|
||||
**正向验证 20+ 条**(返回真实数据、投影正确):`doc +find-doc`、`aitable +resolve-base`/`+resolve-table`/`+list-tables`/`+base-list`/`+find-record`、`mail +find-mail-user`、`chat +my-groups`/`+group-members`/`+at-me`、`contact +org`/`+lookup`、`calendar +today`/`+week`/`+next-event`/`+free`/`+suggest-time`、`todo +overdue`/`+related-tasks`、`attendance +my-attendance`、`report +report-latest`、`oa +my-initiated`、`drive +find-file`、`wiki +resolve-space` 等。
|
||||
|
||||
**真机抓到并修复 8 个真实问题**(assemble 测试抓不到,只有真机能抓):
|
||||
|
||||
| shortcut | 真机发现的问题 | 修复 |
|
||||
|---|---|---|
|
||||
| `contact +resolve-dept` | 对任何真实部门名都「未找到」——真实响应容器 key 是 `deptList`(防御探测清单漏了),deptName 带 `<red>` 高亮、deptId 是数值 | 加 `deptList` 探测 + `stripHighlightTags` + 数值 coerce |
|
||||
| `contact +dept-members` | 消歧消息泄漏 `<red>` 标记 | 复用 `stripHighlightTags` |
|
||||
| `chat +unread-chats` | 每行吐 `unread: null`(底层不返回每会话未读数) | 仅当有值才带该字段 |
|
||||
| `chat +at-me` | 直吐原始两层嵌套、未拍平 | 新增 `atMeFlattenGroups`,拍平 43 条为 `{conversation,sender,text,time}` |
|
||||
| `chat +group-members` | 终结步 raw `CallMCP` 吐冗长 raw(含 avatar 媒体 ID + errorCode 噪音) | 升级 `CallMCPData`+投影 `{name,nick,role,openDingtalkId}` |
|
||||
| `calendar +free` | 吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套 | 投影为 `{who,userId,free,busy:[{start,end}]}` |
|
||||
| `calendar +today` | 吐 17 字段冗长事件(含完整 attendees 数组),与 `+week` 不一致 | 投影为 `{title,start,end,location,eventId}`,对齐 `+week` |
|
||||
| `calendar +suggest-time` | `timeConflictAttendees:[null]` 噪音 + result 包裹 | 拍平 + 丢 null 冲突 → `{suggestions:[{start,end}]}` |
|
||||
|
||||
**后端受限、无法真机正向验证的(非代码问题)**:`chat +chat-messages`/`+search-msg`(读会话消息需更高 PAT 权限 / org 未开 CLI 数据访问)、`minutes +*`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)、`contact +team`(列部门成员 medium-risk 权限墙)。这些命令的**组装链路**经 assemble 测试验证正确,仅无法在本环境跑通后端。
|
||||
|
||||
**一个已澄清的非 bug**:`resolveUser` 对组织内成员(如董鑫阳→userId 202397)真机端到端正常;对外部/资料受限联系人 `search_contact_by_key_word` 只返回 openDingTalkId(name/userId 全 null),此时正确报「没找到」而非瞎猜——钉钉数据模型现实,非代码缺陷。
|
||||
|
||||
> **结论**:真机验证证明「防御式多候选 key 投影」在真实响应上整体成立,并纠正了 8 处「合成测试盖不住」的解析/投影偏差。这是把可用性从「组装正确」提升到「真机输出正确」的关键一环。
|
||||
|
||||
---
|
||||
|
||||
## 3. 效果评估 GSB(vs lark-cli)
|
||||
|
||||
以 lark-cli 各服务领域为基准,评估 dws shortcut 的相对表现。**G**ood=优于/更全,**S**ame=持平,**B**ad=弱于/缺失。
|
||||
|
||||
> ⚠️ 重要前提:两边是**不同 API**(飞书 vs 钉钉),数量不能机械 1:1;覆盖度受钉钉实际能力约束。
|
||||
>
|
||||
> **口径(prune 后重做)**:不再用 shortcut 数(会因去冗余失真),改用**「dws 该服务实际暴露的 distinct MCP tool 数」= helper 命令 ∪ shortcut 覆盖的 tool 合集**——这才代表真实能力,与 helper/shortcut 怎么分层无关。对比 lark 的 shortcut 数(不同 API,只作量级参考)。
|
||||
|
||||
| lark 服务 | lark 数 | dws 对应 | dws tool 覆盖 | (其中 shortcut 补) | GSB | 说明 |
|
||||
|-----------|:---:|---------|:---:|:---:|:---:|------|
|
||||
| im | 21 | chat | **95** | +3 | 🟢 G | 群/消息/机器人能力更全 |
|
||||
| mail | 21 | mail | **43** | +0 | 🟢 G | 覆盖更广(几乎全由 helper 提供,shortcut 曾重复、已 prune) |
|
||||
| doc | 14 | doc | **34** | +4 | 🟢 G | 块级读写更细 |
|
||||
| minutes | 14 | minutes | **25** | +0 | 🟢 G | 录音控制/说话人更全 |
|
||||
| calendar | 12 | calendar | **24** | +5 | 🟢 G | 会议室/ACL/忙闲;shortcut 另补排期智能 |
|
||||
| contact | 2 | contact | **15** | +0 | 🟢 G | 部门/角色/花名册更全 |
|
||||
| wiki | 12 | wiki | **16** | +0 | 🟢 G | 略优 |
|
||||
| base | 87 | aitable | **79** | **+63** | 🟡 S | 基本持平;**helper 仅 16,shortcut 层补齐了绝大部分**(真·gap-fill) |
|
||||
| task | 18 | todo | **20** | +1 | 🟡 S | 持平 |
|
||||
| drive | 26 | drive | **25** | +4 | 🟡 S | 基本持平 |
|
||||
| apps | 63 | devapp | **25** | **+25** | 🔴 B | **helper 无 devapp 命令、25 个全由 shortcut 提供**(纯 gap-fill),但仍少于 lark |
|
||||
| sheets | 84 | sheet | **60** | +1 | 🔴 B | 钉钉表格 MCP 较少;helper 已覆盖,shortcut 曾重复、已 prune |
|
||||
| vc | 18 | — | 0 | — | 🔴 B | 钉钉 conference 无干净 MCP tool |
|
||||
| okr | 13 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
| slides / markdown / whiteboard / note / event | 17 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
|
||||
**dws 独有(lark 无对应服务)**:oa 审批(~20 tool) · attendance 考勤(~38) · report 日志(~7) · ding(~8) · aisearch/live/devdoc(helper 层提供)——钉钉工作流核心,构成差异化。
|
||||
|
||||
### GSB 汇总
|
||||
|
||||
- 🟢 **G(7 领域)**:im/mail/doc/minutes/calendar/contact/wiki——dws tool 覆盖更全。
|
||||
- 🟡 **S(3 领域)**:base/task/drive 量级持平(base 的持平**几乎全靠 shortcut 层 gap-fill**,helper 只有 16)。
|
||||
- 🔴 **B(受限 2 + 不可对齐 6)**:apps/sheets 少于 lark(sheets 受钉钉 API 限,apps 全由 shortcut 补但仍少);vc/okr/slides/markdown/whiteboard/note/event 客观不可对齐(非遗漏)。
|
||||
- **注**:这张表也印证了 1:1 层的真实价值分布——**base/apps 靠 shortcut 补了大量 helper 没有的 tool(gap-fill),而 mail/sheets 的 shortcut 基本是重复 helper(已 prune)**。
|
||||
|
||||
---
|
||||
|
||||
## 4. dws 差异化于 lark 的优势
|
||||
|
||||
### 4.1 执行底座:复用生产级 MCP 通道(架构性优势)
|
||||
lark-cli 每个 shortcut 直连飞书 SDK,错误处理/输出各自实现。dws shortcut 的 `CallMCP` **委托统一的 MCP 调用路径**,一步继承:
|
||||
- **错误分类**:auth 过期 / 未登录 / PAT / 业务错误,自动给出可执行提示;
|
||||
- **`--dry-run` 预览**:不发网络,输出将执行的 tool + 参数;
|
||||
- **`--format`/`--jq`/`--fields`**:机器可解析输出,Agent 友好。
|
||||
|
||||
lark 需在每个命令重复实现这些;dws 由框架统一注入,一致性与维护成本双赢。
|
||||
|
||||
### 4.2 覆盖更全的高频协作能力
|
||||
按 **dws tool 覆盖**(helper∪shortcut,§3 口径):chat 95 / mail 43 / doc 34 / minutes 25 / calendar 24 等即时协作场景,多为 lark 对应服务的 2–4 倍。
|
||||
|
||||
### 4.3 钉钉原生特有能力(lark 完全没有)
|
||||
审批 oa、日志 report、考勤 attendance、DING、企业智能搜索 aisearch —— 这些是钉钉工作流的核心,构成差异化护城河。
|
||||
|
||||
### 4.4 高频自动沉淀(P2,lark 无此设计)
|
||||
基于用户高频使用**主动把常用操作沉淀为自定义 shortcut**(`~/.dws/shortcuts/*.yaml` 运行时加载),让 CLI 越用越顺手。埋点**默认关(opt-in,`DWS_USAGE_TRACKING=1` 开启)**+开启后首跑告知,隐私优先(记形状不记值)。详见 [P2 设计](shortcut-p2-design.md)。**lark-cli 无任何等价能力。**
|
||||
|
||||
### 4.5 AI Agent 友好
|
||||
`--yes` 跳过确认、结构化错误、`--dry-run` 预览、`--print-schema`(规划中)——为 Agent 自动化调用而设计。
|
||||
|
||||
### 4.6 工程质量:全量自动化测试
|
||||
假 Caller 拦截,对全部 366 条(含写/删)做零副作用验证 + tool 名 ground truth 核验,可复跑、可回归。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复盘与后续(loop 持续项)
|
||||
|
||||
### 5.0 关键复盘:1:1 层去冗余(511 → 298)
|
||||
**问题**:建 1:1 shortcut 层之前,**没有先摸清 `internal/helpers/` 已经把哪些 MCP tool 封装成了 `dws <svc> <verb>` 产品命令**。结果对 **213 个 tool 重复封装**——同一个 tool,helper 有 `dws contact user search`、我又造了 `dws contact +search-user`,且这批无投影增量,纯重复。
|
||||
**纠偏**:按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 213 条,1:1 层 511 → **298**(保留 233 填空白 + 65 有投影),总数 579 → **366**,服务 19 → **16**(aisearch/live/devdoc 整包移除)。全绿、真机复验保留命令仍可用。
|
||||
**教训**:**做封装层前先审已有封装**。对齐 lark「每一条 shortcut」时,应先问「dws 这边是不是已经有等价命令了」,而不是无脑对齐。这是本项目最大的方法论盲点。
|
||||
|
||||
1. ~~补 apps(↔devapp)~~ ✅ 已完成:新增 30 个 devapp shortcut。
|
||||
2. **P2 落地**(差异化能力,lark 无):
|
||||
- ✅ **P2-1 usage 埋点**:装饰 MCP 调用唯一必经点记录 `~/.dws/usage.jsonl`(**记形状不记值**,敏感/自由文本字段脱敏;**默认关/opt-in**,`DWS_USAGE_TRACKING=1` 开启、开启后首跑告知)。端到端验证通过。
|
||||
- ✅ **stats/list**:`dws shortcut list [--service]`、`dws shortcut stats [--top N] [--purge]`(按 `(product,tool,arg_keys)` 聚合、识别固定值 fixed_args)。
|
||||
- ✅ **P2-2 suggest**:`dws shortcut suggest [--min N]` 把高频分组转成「建议沉淀的 +command」候选(含固定/可变参数拆分)。
|
||||
- ✅ **P2-3 YAML 自定义 shortcut 沉淀闭环**:`dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 下次运行 `userdef.Load()` 编译成 Shortcut 注册(复用同一 runner;`${flag}` 绑定+常量;与内建冲突自动跳过)。**端到端验证通过**:add→重载→`dws <svc> +<cmd>` 可用,dry-run 组装正确。
|
||||
- ⏳ **P2-4 nudge**:命中高频候选时主动提示(TTY、频控、可永久关闭)。
|
||||
|
||||
> 至此,**「高频使用 → 建议 → 一键沉淀 → 自定义 shortcut 运行时生效」完整闭环已打通**——这是 lark-cli 完全没有的差异化能力。
|
||||
3. **深化 sheets**:随钉钉表格 MCP 能力增强补齐。
|
||||
4. **实机全读回归**:登录态下对全部只读 shortcut 做真实调用回归(需有效 token + 真实资源 ID)。
|
||||
5. **不可对齐项归档**:okr/slides/whiteboard/note/vc/event 明确标注为钉钉无能力,避免误解为遗漏。
|
||||
|
||||
---
|
||||
|
||||
## 附:一句话结论
|
||||
|
||||
> dws 已把钉钉侧**能对齐的主要服务全部对齐**(16 服务 / **366 shortcut** = 298 封装 + 68 智能编排),在即时协作能力上显著优于 lark,并拥有审批/考勤/日志/DING 等钉钉原生差异化能力与「高频自动沉淀」独有设计;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量自动化验证。
|
||||
@@ -49,6 +49,8 @@ var AllowedMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
|
||||
}
|
||||
|
||||
var newHTTPRequest = http.NewRequestWithContext
|
||||
|
||||
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
|
||||
type RawAPIRequest struct {
|
||||
Method string // GET, POST, PUT, PATCH, DELETE
|
||||
@@ -112,7 +114,7 @@ func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse,
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
httpReq, err := newHTTPRequest(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
type failingReader struct{ err error }
|
||||
|
||||
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
|
||||
type failingWriter struct{ err error }
|
||||
|
||||
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestCrossPlatformCoverageDryRunAndParseCoverageEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
base string
|
||||
path string
|
||||
}{
|
||||
{DefaultBaseURL, "/v1.0/test"},
|
||||
{LegacyBaseURL, "/topapi/test"},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
err := PrintDryRun(&out, RawAPIRequest{
|
||||
Method: "post", Path: tc.path,
|
||||
Params: map[string]any{"page": 1}, Data: map[string]any{"name": "value"},
|
||||
}, tc.base, "token-value")
|
||||
if err != nil || !strings.Contains(out.String(), "Dry Run") || !strings.Contains(out.String(), "toke****") {
|
||||
t.Fatalf("PrintDryRun(%s) = %q, %v", tc.base, out.String(), err)
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := PrintDryRun(&out, RawAPIRequest{Method: "get", Path: "/x", Params: map[string]any{"bad": make(chan int)}, Data: make(chan int)}, DefaultBaseURL, "tiny"); err != nil {
|
||||
t.Fatalf("PrintDryRun unsupported preview: %v", err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("read failed")
|
||||
if _, err := ParseJSONMap("-", "--params", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ParseJSONMap read error = %v", err)
|
||||
}
|
||||
if got, err := ParseJSONMap("-", "--params", strings.NewReader(" \n")); err != nil || got != nil {
|
||||
t.Fatalf("ParseJSONMap empty stdin = %#v, %v", got, err)
|
||||
}
|
||||
if _, err := ParseOptionalBody("POST", "-", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ParseOptionalBody read error = %v", err)
|
||||
}
|
||||
if got, err := ParseOptionalBody("POST", "-", strings.NewReader(" \n")); err != nil || got != nil {
|
||||
t.Fatalf("ParseOptionalBody empty stdin = %#v, %v", got, err)
|
||||
}
|
||||
if _, err := ParseOptionalBody("POST", "{", strings.NewReader("")); err == nil {
|
||||
t.Fatal("invalid optional body should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResponseHandlingCoverageEdges(t *testing.T) {
|
||||
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
|
||||
textHeader := http.Header{"Content-Type": []string{"text/plain"}}
|
||||
var out, errOut bytes.Buffer
|
||||
opts := ResponseOptions{Format: output.FormatJSON, Out: &out, ErrOut: &errOut}
|
||||
|
||||
if err := HandleResponse(&RawAPIResponse{StatusCode: 500, Header: textHeader, Body: []byte(" failed ")}, opts); err == nil {
|
||||
t.Fatal("plain HTTP error should fail")
|
||||
}
|
||||
for _, body := range [][]byte{nil, []byte("{")} {
|
||||
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: body}, opts); err == nil {
|
||||
t.Errorf("invalid JSON body %q should fail", body)
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(`{"ok":true}`)}, opts); err != nil || !strings.Contains(out.String(), "ok") {
|
||||
t.Fatalf("successful JSON response = %q, %v", out.String(), err)
|
||||
}
|
||||
for _, payload := range []string{
|
||||
`{"errcode":1}`,
|
||||
`{"message":"message failure"}`,
|
||||
`{"error":"error failure"}`,
|
||||
`{}`,
|
||||
} {
|
||||
status := 200
|
||||
if !strings.Contains(payload, "errcode") {
|
||||
status = 500
|
||||
}
|
||||
if err := HandleResponse(&RawAPIResponse{StatusCode: status, Header: jsonHeader, Body: []byte(payload)}, opts); err == nil {
|
||||
t.Errorf("business/HTTP payload %s should fail", payload)
|
||||
}
|
||||
}
|
||||
if err := checkDingTalkError([]any{1}, 200); err != nil || checkDingTalkError(map[string]any{"errcode": 0}, 200) != nil {
|
||||
t.Fatal("successful DingTalk response classified as error")
|
||||
}
|
||||
|
||||
if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: textHeader, Body: []byte("binary")}, opts); err == nil {
|
||||
t.Fatal("binary response without filename should fail")
|
||||
}
|
||||
invalidCD := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="unterminated`}}
|
||||
if inferFilename(invalidCD) != "" {
|
||||
t.Fatal("invalid content disposition should not infer filename")
|
||||
}
|
||||
if inferFilename(http.Header{}) != "" {
|
||||
t.Fatal("missing content disposition should not infer filename")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
blockedParent := filepath.Join(dir, "file")
|
||||
if err := os.WriteFile(blockedParent, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.OutputPath = filepath.Join(blockedParent, "child.bin")
|
||||
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
|
||||
t.Fatal("binary mkdir failure should fail")
|
||||
}
|
||||
opts.OutputPath = dir
|
||||
if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
|
||||
t.Fatal("binary write to directory should fail")
|
||||
}
|
||||
opts.OutputPath = ""
|
||||
inferred := filepath.Join(dir, "inferred.bin")
|
||||
header := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="` + inferred + `"`}}
|
||||
if err := handleBinaryResponse(&RawAPIResponse{Header: header, Body: []byte("bytes")}, opts); err != nil {
|
||||
t.Fatalf("inferred binary save: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "已保存") {
|
||||
t.Fatalf("binary status = %q", errOut.String())
|
||||
}
|
||||
|
||||
for _, ct := range []string{" application/json; charset=utf-8 ", "text/json", "application/problem+json", "text/plain"} {
|
||||
_ = isJSONContentType(ct)
|
||||
}
|
||||
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), json.Number("bad"), "5"} {
|
||||
_ = toFloat64(value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePaginationParsingAndInjectionEdges(t *testing.T) {
|
||||
jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
|
||||
for _, resp := range []*RawAPIResponse{
|
||||
{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: []byte("x")},
|
||||
{StatusCode: 200, Header: jsonHeader},
|
||||
{StatusCode: 200, Header: jsonHeader, Body: []byte("{")},
|
||||
{StatusCode: 500, Header: jsonHeader, Body: []byte(`{"message":"bad"}`)},
|
||||
} {
|
||||
if _, _, _, err := parsePaginatedResponse(resp); err == nil {
|
||||
t.Errorf("parsePaginatedResponse(%#v) should fail", resp)
|
||||
}
|
||||
}
|
||||
responses := []struct {
|
||||
body string
|
||||
more bool
|
||||
token string
|
||||
}{
|
||||
{`{"result":{"has_more":true,"next_cursor":12}}`, true, "12"},
|
||||
{`{"has_more":true,"next_cursor":13}`, true, "13"},
|
||||
{`{"next_token":"next"}`, true, "next"},
|
||||
{`{"result":[],"has_more":false}`, false, ""},
|
||||
}
|
||||
for _, tc := range responses {
|
||||
_, more, token, err := parsePaginatedResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(tc.body)})
|
||||
if err != nil || more != tc.more || token != tc.token {
|
||||
t.Errorf("pagination %s = %v, %q, %v", tc.body, more, token, err)
|
||||
}
|
||||
}
|
||||
|
||||
getCases := []RawAPIRequest{
|
||||
{Method: "GET"},
|
||||
{Method: "GET", Params: map[string]any{"cursor": "old"}},
|
||||
{Method: "GET", Params: map[string]any{"next_token": "old"}},
|
||||
{Method: "POST", Data: map[string]any{"cursor": "old"}},
|
||||
{Method: "PUT", Data: map[string]any{}},
|
||||
{Method: "POST", Data: "not-a-map"},
|
||||
}
|
||||
for _, req := range getCases {
|
||||
_ = injectPageToken(req, "new")
|
||||
}
|
||||
logf(nil, "ignored")
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func jsonHTTPResponse(body string) *http.Response {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePaginationControlFlowEdges(t *testing.T) {
|
||||
wantErr := errors.New("transport failed")
|
||||
client := NewClient("token", DefaultBaseURL)
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
})
|
||||
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); err == nil {
|
||||
t.Fatal("first page parse error should fail")
|
||||
}
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
|
||||
if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("first page transport error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return jsonHTTPResponse(`{"next_token":"next"}`), nil
|
||||
}
|
||||
return nil, wantErr
|
||||
})
|
||||
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1}); err == nil || len(pages) != 1 {
|
||||
t.Fatalf("later transport error pages=%d err=%v", len(pages), err)
|
||||
}
|
||||
|
||||
calls = 0
|
||||
var logs bytes.Buffer
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return jsonHTTPResponse(`{"next_token":"next"}`), nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
})
|
||||
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "解析失败") {
|
||||
t.Fatalf("later parse failure pages=%d logs=%q err=%v", len(pages), logs.String(), err)
|
||||
}
|
||||
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return jsonHTTPResponse(`{"next_token":"next"}`), nil
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if pages, err := client.PaginateAll(ctx, RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 10}); !errors.Is(err, context.Canceled) || len(pages) != 1 {
|
||||
t.Fatalf("pagination cancellation pages=%d err=%v", len(pages), err)
|
||||
}
|
||||
logs.Reset()
|
||||
if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageLimit: 1, PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "安全上限") {
|
||||
t.Fatalf("pagination safety cap pages=%d logs=%q err=%v", len(pages), logs.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientAndValidationFailureEdges(t *testing.T) {
|
||||
client := NewClient("token", DefaultBaseURL)
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://api.dingtalk.com/%zz"}); err == nil {
|
||||
t.Fatal("Do with invalid URL should fail")
|
||||
}
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://example.test/x"}); err == nil {
|
||||
t.Fatal("Do to untrusted host should fail")
|
||||
}
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "POST", Path: "/x", Data: make(chan int)}); err == nil {
|
||||
t.Fatal("unmarshalable request body should fail")
|
||||
}
|
||||
if _, err := client.buildURL("https://api.dingtalk.com/%zz", nil); err == nil {
|
||||
t.Fatal("invalid URL should fail")
|
||||
}
|
||||
oldNewRequest := newHTTPRequest
|
||||
t.Cleanup(func() { newHTTPRequest = oldNewRequest })
|
||||
wantCreateErr := errors.New("request creation failed")
|
||||
newHTTPRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, wantCreateErr }
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantCreateErr) {
|
||||
t.Fatalf("request creation error = %v", err)
|
||||
}
|
||||
newHTTPRequest = oldNewRequest
|
||||
wantErr := errors.New("request failed")
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("HTTP transport error = %v", err)
|
||||
}
|
||||
client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(failingReader{err: wantErr})}, nil
|
||||
})
|
||||
if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("response read error = %v", err)
|
||||
}
|
||||
|
||||
if ValidateTargetHost("http://%zz") == nil {
|
||||
t.Fatal("invalid target URL should fail")
|
||||
}
|
||||
for _, r := range []rune{0x200B, 0xFEFF, 0x202A, 0x2028, 0x2066, 0x061C, 0xFDD0} {
|
||||
if !isDangerousUnicode(r) || ValidateUserInput("x"+string(r), "field") == nil {
|
||||
t.Errorf("dangerous rune %U was accepted", r)
|
||||
}
|
||||
}
|
||||
if isDangerousUnicode('中') || ValidateUserInput("safe\t\n中文", "field") != nil {
|
||||
t.Fatal("safe Unicode/input was rejected")
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,29 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type legacyTokenGetter interface {
|
||||
GetToken() (string, string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
newAccessTokenProvider = func(configDir string) accessTokenGetter {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
return provider
|
||||
}
|
||||
newLegacyTokenManager = func(configDir string) legacyTokenGetter {
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
return manager
|
||||
}
|
||||
)
|
||||
|
||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
provider := newAccessTokenProvider(configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token), nil
|
||||
@@ -40,8 +56,7 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
return "", tokenErr
|
||||
}
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
manager := newLegacyTokenManager(configDir)
|
||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
||||
return strings.TrimSpace(leg), nil
|
||||
}
|
||||
|
||||
+17
-11
@@ -38,6 +38,19 @@ type apiFlags struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type appTokenGetter interface {
|
||||
GetToken(context.Context) (string, error)
|
||||
}
|
||||
|
||||
var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter {
|
||||
return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret}
|
||||
}
|
||||
|
||||
var (
|
||||
apiClientID = authpkg.ClientID
|
||||
apiClientSecret = authpkg.ClientSecret
|
||||
)
|
||||
|
||||
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
|
||||
func newAPICommand(flags *GlobalFlags) *cobra.Command {
|
||||
af := &apiFlags{}
|
||||
@@ -271,10 +284,7 @@ func parseQueryStringToJSON(rawQuery string) string {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
data, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
data, _ := json.Marshal(paramsMap)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -289,8 +299,8 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
|
||||
}
|
||||
|
||||
// Resolve app credentials (clientID/clientSecret).
|
||||
appKey := authpkg.ClientID()
|
||||
appSecret := authpkg.ClientSecret()
|
||||
appKey := apiClientID()
|
||||
appSecret := apiClientSecret()
|
||||
|
||||
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
|
||||
return "", apperrors.NewAuth(
|
||||
@@ -308,11 +318,7 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
|
||||
|
||||
// Use AppTokenProvider for automatic caching and refresh.
|
||||
configDir := defaultConfigDir()
|
||||
provider := &authpkg.AppTokenProvider{
|
||||
ConfigDir: configDir,
|
||||
AppKey: appKey,
|
||||
AppSecret: appSecret,
|
||||
}
|
||||
provider := newAppTokenProvider(configDir, appKey, appSecret)
|
||||
token, err := provider.GetToken(ctx)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type appFailWriter struct{ err error }
|
||||
|
||||
func (w appFailWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
type fakeAccessTokenGetter struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
|
||||
return g.token, g.err
|
||||
}
|
||||
|
||||
type fakeLegacyTokenGetter struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeLegacyTokenGetter) GetToken() (string, string, error) {
|
||||
return g.token, "test", g.err
|
||||
}
|
||||
|
||||
type fakeAppTokenGetter struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (g fakeAppTokenGetter) GetToken(context.Context) (string, error) { return g.token, g.err }
|
||||
|
||||
type fakeSkillDirEntry struct{ dir bool }
|
||||
|
||||
func (e fakeSkillDirEntry) Name() string { return "entry" }
|
||||
func (e fakeSkillDirEntry) IsDir() bool { return e.dir }
|
||||
func (e fakeSkillDirEntry) Type() os.FileMode { return 0 }
|
||||
func (e fakeSkillDirEntry) Info() (os.FileInfo, error) { return nil, nil }
|
||||
|
||||
func docPreflightServer(t *testing.T, result map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": result,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocDownloadPreflightCoverage(t *testing.T) {
|
||||
runner := &runtimeRunner{}
|
||||
base := executor.Invocation{CanonicalProduct: "doc", Tool: "download_file", Params: map[string]any{"nodeId": " node "}}
|
||||
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{CanonicalProduct: "DOC", Tool: "download_file"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isDocDownloadInvocation(base) || docDownloadNodeID(map[string]any{"node": " n "}) != "n" || docDownloadNodeID(map[string]any{"dentryUuid": " d "}) != "d" || docDownloadNodeID(map[string]any{"nodeId": 1}) != "" {
|
||||
t.Fatal("doc download invocation helpers returned unexpected values")
|
||||
}
|
||||
if documentInfoExtension(map[string]any{"data": map[string]any{"extension": " doc "}}) != "doc" ||
|
||||
documentInfoExtension(map[string]any{"extension": " pdf "}) != "pdf" ||
|
||||
stringAtPath(map[string]any{"x": "value"}, "x", "nested") != "" ||
|
||||
stringAtPath(map[string]any{"x": 1}, "x") != "" {
|
||||
t.Fatal("document extension helpers returned unexpected values")
|
||||
}
|
||||
if unsupportedAXLSDownloadError() == nil {
|
||||
t.Fatal("missing AXLS validation error")
|
||||
}
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
hookErr := errors.New("classified")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
result map[string]any
|
||||
hooks *edition.Hooks
|
||||
want string
|
||||
}{
|
||||
{name: "ok", result: map[string]any{"content": map[string]any{"result": map[string]any{"extension": "docx"}}}, hooks: &edition.Hooks{}},
|
||||
{name: "edition classifier", result: map[string]any{"content": map[string]any{}}, hooks: &edition.Hooks{ClassifyToolResult: func(map[string]any) error { return hookErr }}, want: "classified"},
|
||||
{name: "pat", result: map[string]any{"content": map[string]any{"errorCode": "PAT_NO_PERMISSION"}}, hooks: &edition.Hooks{}, want: "PAT_NO_PERMISSION"},
|
||||
{name: "mcp error", result: map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "mcp failed"}}}, hooks: &edition.Hooks{}, want: "mcp failed"},
|
||||
{name: "business error", result: map[string]any{"content": map[string]any{"success": false, "errorMsg": "business failed"}}, hooks: &edition.Hooks{}, want: "business failed"},
|
||||
{name: "axls", result: map[string]any{"content": map[string]any{"data": map[string]any{"extension": "AXLS"}}}, hooks: &edition.Hooks{}, want: "extension=axls"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
edition.Override(tc.hooks)
|
||||
server := docPreflightServer(t, tc.result)
|
||||
defer server.Close()
|
||||
client := transport.NewClient(nil)
|
||||
client.TrustedDomains = []string{"127.0.0.1"}
|
||||
err := runner.preflightDocDownload(context.Background(), client, server.URL, base)
|
||||
if tc.want == "" && err != nil {
|
||||
t.Fatalf("preflight error = %v", err)
|
||||
}
|
||||
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||
t.Fatalf("preflight error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
server := docPreflightServer(t, map[string]any{})
|
||||
endpoint := server.URL
|
||||
server.Close()
|
||||
client := transport.NewClient(nil)
|
||||
client.TrustedDomains = []string{"127.0.0.1"}
|
||||
client.MaxRetries = 0
|
||||
if err := runner.preflightDocDownload(context.Background(), client, endpoint, base); err == nil {
|
||||
t.Fatal("network preflight failure succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootHelpRemainingCoverage(t *testing.T) {
|
||||
configureRootHelp(nil)
|
||||
renderRootGlobalFlags(nil)
|
||||
if visiblePersistentFlags(nil) != nil || formatRootFlag(nil) != "" || commandShort(nil) != "" || visibleMCPRootCommands(nil) != nil || visibleUtilityRootCommands(nil) != nil {
|
||||
t.Fatal("nil root helper contract changed")
|
||||
}
|
||||
|
||||
oldEdition := edition.Get()
|
||||
edition.Override(&edition.Hooks{VisibleProducts: func() []string { return []string{"service"} }})
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
root := &cobra.Command{Use: "root", Long: "long help"}
|
||||
root.SetOut(io.Discard)
|
||||
root.PersistentFlags().StringP("value", "x", "", "value")
|
||||
root.PersistentFlags().Bool("hidden", false, "hidden")
|
||||
_ = root.PersistentFlags().MarkHidden("hidden")
|
||||
root.AddCommand(&cobra.Command{Use: "service", Short: "service"}, &cobra.Command{Use: "utility", Short: "utility"})
|
||||
configureRootHelp(root)
|
||||
if err := root.Commands()[0].Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"help", "missing"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"help", "utility"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := formatRootFlag(root.PersistentFlags().Lookup("value")); !strings.Contains(got, "-x") {
|
||||
t.Fatalf("formatted flag = %q", got)
|
||||
}
|
||||
if got := formatRootFlag(root.PersistentFlags().Lookup("hidden")); !strings.Contains(got, "--hidden") {
|
||||
t.Fatalf("formatted long flag = %q", got)
|
||||
}
|
||||
_ = commandShort(&cobra.Command{Use: "help", Short: "Help about any command"})
|
||||
renderRootGlobalFlags(&cobra.Command{Use: "no-flags"})
|
||||
edition.Override(&edition.Hooks{})
|
||||
SetDynamicServers(nil)
|
||||
_ = visibleMCPRootCommands(root)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
||||
oldHome, oldExe, oldEval := userHomeDir, executablePath, evaluateSymlink
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
userHomeDir, executablePath, evaluateSymlink = oldHome, oldExe, oldEval
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
edition.Override(&edition.Hooks{})
|
||||
userHomeDir = func() (string, error) { return "", errors.New("home") }
|
||||
executablePath = func() (string, error) { return "", errors.New("exe") }
|
||||
if got := defaultConfigDir(); got != ".dws" {
|
||||
t.Fatalf("fallback config dir = %q", got)
|
||||
}
|
||||
executablePath = func() (string, error) { return filepath.Join("", "tmp", "dws"), nil }
|
||||
evaluateSymlink = func(string) (string, error) { return "", errors.New("link") }
|
||||
if got := exeRelativeConfigDir(); !strings.HasSuffix(got, filepath.Join("tmp", ".dws")) {
|
||||
t.Fatalf("executable config dir = %q", got)
|
||||
}
|
||||
userHomeDir = func() (string, error) { return "/home/test", nil }
|
||||
if got := defaultConfigDir(); got != filepath.Join("/home/test", ".dws") {
|
||||
t.Fatalf("home config dir = %q", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "/edition" }})
|
||||
if got := defaultConfigDir(); got != "/edition" {
|
||||
t.Fatalf("edition config dir = %q", got)
|
||||
}
|
||||
|
||||
oldProvider, oldManager := newAccessTokenProvider, newLegacyTokenManager
|
||||
t.Cleanup(func() { newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldManager })
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
|
||||
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||
t.Fatalf("decryption error = %v", err)
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
||||
t.Fatalf("legacy token = %q, %v", got, err)
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} }
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} }
|
||||
edition.Override(&edition.Hooks{})
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
|
||||
t.Fatal("auxiliary provider failure succeeded")
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||
t.Fatalf("auxiliary decryption error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", other)
|
||||
ResetRuntimeTokenCache()
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
|
||||
t.Fatal("current config without credentials succeeded")
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
if !strings.Contains(noCredentialsError().Error(), "认证") {
|
||||
t.Fatal("embedded credentials error changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
|
||||
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
|
||||
oldStop := stopStdio
|
||||
t.Cleanup(func() {
|
||||
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
|
||||
stopStdio = oldStop
|
||||
stdioMu.Lock()
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
_ = oldFactory(t.TempDir())
|
||||
markAccessTokenStale = func(string) error { return fail }
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
|
||||
t.Fatalf("mark stale error = %v", err)
|
||||
}
|
||||
markAccessTokenStale = func(string) error { return nil }
|
||||
for _, tc := range []struct {
|
||||
getter fakeAccessTokenGetter
|
||||
want string
|
||||
}{
|
||||
{getter: fakeAccessTokenGetter{err: fail}, want: "failure"},
|
||||
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
|
||||
{getter: fakeAccessTokenGetter{token: " refreshed "}},
|
||||
} {
|
||||
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
|
||||
got, err := ForceRefreshAccessToken(context.Background(), "config")
|
||||
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||
t.Fatalf("refresh error = %v, want %q", err, tc.want)
|
||||
}
|
||||
if tc.want == "" && (err != nil || got != "refreshed") {
|
||||
t.Fatalf("refreshed token = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
stopStdio = func(*transport.StdioClient) error { return fail }
|
||||
RegisterStdioClient("all", transport.NewStdioClient("unused", nil, nil))
|
||||
StopAllStdioClients()
|
||||
RegisterStdioClient("one", transport.NewStdioClient("unused", nil, nil))
|
||||
if !StopStdioClient("one") {
|
||||
t.Fatal("registered stdio client not stopped")
|
||||
}
|
||||
RegisterStdioClient("plugin/server", transport.NewStdioClient("unused", nil, nil))
|
||||
if got := StopStdioClientsByPlugin("plugin"); got != 1 {
|
||||
t.Fatalf("stopped plugin clients = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeOverlay := filepath.Join(root, "overlay.json")
|
||||
if err := os.WriteFile(writeOverlay, []byte(`{"toolOverrides":{"tool":{}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []json.RawMessage{
|
||||
json.RawMessage(`"missing.json"`),
|
||||
json.RawMessage(`"unterminated`),
|
||||
json.RawMessage(`{`),
|
||||
json.RawMessage(`"overlay.json"`),
|
||||
json.RawMessage(`{"id":"","command":"","toolOverrides":{"tool":{}}}`),
|
||||
} {
|
||||
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: raw}}}}
|
||||
overlay := resolveStdioOverlay(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)})
|
||||
if overlay.ID == "" || overlay.Command == "" {
|
||||
t.Fatalf("overlay defaults missing: %#v", overlay)
|
||||
}
|
||||
}
|
||||
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: json.RawMessage(`{}`)}}}}
|
||||
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server"}); descriptor.Endpoint == "" {
|
||||
t.Fatalf("empty overlay descriptor = %#v", descriptor)
|
||||
}
|
||||
p.Manifest.MCPServers["server"].CLI = json.RawMessage(`{"toolOverrides":{"tool":{}}}`)
|
||||
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)}); descriptor.Endpoint == "" {
|
||||
t.Fatalf("stdio overlay registration = %#v", descriptor)
|
||||
}
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||
t.Fatalf("runtime command path = %#v", got)
|
||||
}
|
||||
os.Args = oldArgs
|
||||
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
if hostControlProviderFromEnv() != "" {
|
||||
t.Fatal("host control enabled without agent code")
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent")
|
||||
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
|
||||
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("default claw type = %q", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{MergeHeaders: func(map[string]string) map[string]string { return map[string]string{"claw-type": "custom"} }})
|
||||
if got := effectiveClawType(); got != "custom" {
|
||||
t.Fatalf("custom claw type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", "configured")
|
||||
configCmd := &cobra.Command{Use: "config"}
|
||||
var configOut bytes.Buffer
|
||||
configCmd.SetOut(&configOut)
|
||||
if err := writeConfigJSON(configCmd, filterVisible(nil), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := newConfigListCommand()
|
||||
list.SetOut(io.Discard)
|
||||
_ = list.Flags().Set("category", "core")
|
||||
_ = list.Flags().Set("show-values", "true")
|
||||
_ = list.Flags().Set("show-hidden", "true")
|
||||
_ = list.Flags().Set("json", "true")
|
||||
if err := runConfigList(list, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cacheRoot := &cobra.Command{Use: "root"}
|
||||
cacheRoot.PersistentFlags().String("format", "", "")
|
||||
cacheCmd := &cobra.Command{Use: "cache"}
|
||||
cacheRoot.AddCommand(cacheCmd)
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fail := errors.New("write")
|
||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAPIAndTimingRemainingCoverage(t *testing.T) {
|
||||
oldProvider := newAppTokenProvider
|
||||
oldClientID, oldClientSecret := apiClientID, apiClientSecret
|
||||
oldMarshal, oldMkdir := timingMarshalIndent, timingMkdirAll
|
||||
oldWrite, oldRemove, oldRename := timingWriteFile, timingRemove, timingRename
|
||||
oldRead, oldHome := timingReadFile, timingUserHomeDir
|
||||
t.Cleanup(func() {
|
||||
newAppTokenProvider = oldProvider
|
||||
apiClientID, apiClientSecret = oldClientID, oldClientSecret
|
||||
timingMarshalIndent, timingMkdirAll = oldMarshal, oldMkdir
|
||||
timingWriteFile, timingRemove, timingRename = oldWrite, oldRemove, oldRename
|
||||
timingReadFile, timingUserHomeDir = oldRead, oldHome
|
||||
authpkg.SetClientID("")
|
||||
authpkg.SetClientSecret("")
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
apiClientID = func() string { return "" }
|
||||
apiClientSecret = func() string { return "" }
|
||||
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
|
||||
t.Fatal("missing raw API credentials succeeded")
|
||||
}
|
||||
apiClientID = func() string { return "<placeholder>" }
|
||||
apiClientSecret = func() string { return "secret" }
|
||||
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
|
||||
t.Fatal("placeholder raw API credentials succeeded")
|
||||
}
|
||||
apiClientID, apiClientSecret = authpkg.ClientID, authpkg.ClientSecret
|
||||
authpkg.SetClientID("app-key")
|
||||
authpkg.SetClientSecret("app-secret")
|
||||
for _, tc := range []struct {
|
||||
getter fakeAppTokenGetter
|
||||
want string
|
||||
}{
|
||||
{getter: fakeAppTokenGetter{err: fail}, want: "failure"},
|
||||
{getter: fakeAppTokenGetter{token: " "}, want: "为空"},
|
||||
{getter: fakeAppTokenGetter{token: " token "}},
|
||||
} {
|
||||
newAppTokenProvider = func(string, string, string) appTokenGetter { return tc.getter }
|
||||
got, err := resolveRawAPIToken(context.Background(), "")
|
||||
if tc.want != "" && (err == nil || !containsText(err.Error(), tc.want)) {
|
||||
t.Fatalf("raw token error = %v, want %q", err, tc.want)
|
||||
}
|
||||
if tc.want == "" && (err != nil || got != "token") {
|
||||
t.Fatalf("raw token = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
endpoint := server.URL
|
||||
server.Close()
|
||||
apiclient.AllowedHosts["127.0.0.1"] = true
|
||||
t.Cleanup(func() { delete(apiclient.AllowedHosts, "127.0.0.1") })
|
||||
cmd := &cobra.Command{Use: "api"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := runAPI(cmd, []string{"GET", "/path"}, &GlobalFlags{Token: "token", Timeout: 1}, &apiFlags{baseURL: endpoint}); err == nil {
|
||||
t.Fatal("closed raw API endpoint succeeded")
|
||||
}
|
||||
|
||||
collector := NewTimingCollector()
|
||||
collector.Print(io.Discard)
|
||||
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
|
||||
timingMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
|
||||
collector.WriteReportIfEnabled("v", "cmd")
|
||||
timingMarshalIndent = oldMarshal
|
||||
timingUserHomeDir = func() (string, error) { return "", fail }
|
||||
t.Setenv(PerfReportEnv, "auto")
|
||||
collector.WriteReportIfEnabled("v", "cmd")
|
||||
if defaultPerfReportPath() != "" {
|
||||
t.Fatal("home-dir failure produced a report path")
|
||||
}
|
||||
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
|
||||
timingMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
collector.WriteReportIfEnabled("v", "cmd")
|
||||
timingMkdirAll = oldMkdir
|
||||
timingWriteFile = func(string, []byte, os.FileMode) error { return fail }
|
||||
removed := false
|
||||
timingRemove = func(string) error { removed = true; return nil }
|
||||
collector.WriteReportIfEnabled("v", "cmd")
|
||||
if !removed {
|
||||
t.Fatal("failed temporary report was not removed")
|
||||
}
|
||||
timingWriteFile = oldWrite
|
||||
timingRemove = oldRemove
|
||||
renamed := false
|
||||
timingRename = func(string, string) error { renamed = true; return fail }
|
||||
collector.WriteReportIfEnabled("v", "cmd")
|
||||
if !renamed {
|
||||
t.Fatal("report rename was not attempted")
|
||||
}
|
||||
timingReadFile = func(string) ([]byte, error) { return []byte("{"), nil }
|
||||
if _, err := LoadLatestReport(); err == nil {
|
||||
t.Fatal("malformed performance report succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
SetDynamicServers(nil)
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
for _, raw := range []string{
|
||||
"not a url",
|
||||
"https://mcp.dingtalk.com/path?q=1#fragment",
|
||||
"https://pre-mcp.example.test:8443/path/",
|
||||
"https://mcp.example.test/path/",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := defaultPATGatewayBaseURL(); got == "" {
|
||||
t.Fatalf("gateway for %q is blank", raw)
|
||||
}
|
||||
}
|
||||
|
||||
endpoints := map[string]string{}
|
||||
products := map[string]bool{}
|
||||
aliases := map[string]string{}
|
||||
tools := map[string]string{}
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://server.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "id", Command: "command", Aliases: []string{"alias", " "},
|
||||
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
|
||||
},
|
||||
}, endpoints, products, aliases, tools)
|
||||
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
|
||||
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
|
||||
}
|
||||
|
||||
SetDynamicServers(nil)
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = map[string]string{}
|
||||
dynamicProducts = map[string]bool{}
|
||||
dynamicAliases = map[string]string{}
|
||||
dynamicToolEndpoints = map[string]string{}
|
||||
dynamicMu.Unlock()
|
||||
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
|
||||
t.Fatal("cold-start PAT fallback did not resolve")
|
||||
}
|
||||
SetDynamicServers(nil)
|
||||
if _, ok := directRuntimeEndpoint(" ", " "); ok {
|
||||
t.Fatal("blank runtime endpoint resolved")
|
||||
}
|
||||
t.Setenv("DINGTALK_CUSTOM_MCP_URL", "https://override.test")
|
||||
if got, ok := directRuntimeEndpoint("custom", ""); !ok || got != "https://override.test" {
|
||||
t.Fatalf("environment runtime endpoint = %q, %v", got, ok)
|
||||
}
|
||||
if got, ok := directRuntimeEndpoint(devappProductID, ""); !ok || got == "" {
|
||||
t.Fatal("devapp fallback did not resolve")
|
||||
}
|
||||
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
|
||||
t.Fatal("PAT fallback did not resolve")
|
||||
}
|
||||
edition.Override(&edition.Hooks{
|
||||
StaticServers: func() []edition.ServerInfo { return []edition.ServerInfo{{ID: "other", Endpoint: ""}} },
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{ID: "other", Endpoint: "https://other.test", Prefixes: []string{" ", "wanted"}}}
|
||||
},
|
||||
})
|
||||
if got, ok := directRuntimeEndpoint("wanted", ""); !ok || got != "https://other.test" {
|
||||
t.Fatalf("edition runtime endpoint = %q, %v", got, ok)
|
||||
}
|
||||
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints = nil, nil, nil, nil
|
||||
dynamicMu.Unlock()
|
||||
AppendDynamicServer(mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://append.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "append", Command: "append-command", Aliases: []string{"append-alias"},
|
||||
Tools: []mcptypes.CLITool{{Name: "append-tool"}},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{
|
||||
"append-override": {}, "skip": {ServerOverride: "other"}, " ": {},
|
||||
},
|
||||
},
|
||||
})
|
||||
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
|
||||
t.Fatalf("append override endpoint = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(value, substring string) bool {
|
||||
for i := 0; i+len(substring) <= len(value); i++ {
|
||||
if value[i:i+len(substring)] == substring {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEmbeddedSkillAndTinyCommandsRemainingCoverage(t *testing.T) {
|
||||
oldStat, oldTemp, oldRemove := embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll
|
||||
oldWalk, oldRead := embeddedSkillWalkDir, embeddedSkillReadFile
|
||||
oldMkdir, oldWrite := embeddedSkillMkdirAll, embeddedSkillWriteFile
|
||||
t.Cleanup(func() {
|
||||
embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll = oldStat, oldTemp, oldRemove
|
||||
embeddedSkillWalkDir, embeddedSkillReadFile = oldWalk, oldRead
|
||||
embeddedSkillMkdirAll, embeddedSkillWriteFile = oldMkdir, oldWrite
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
embeddedSkillStat = func(string) (os.FileInfo, error) { return nil, nil }
|
||||
embeddedSkillMkdirTemp = func(string, string) (string, error) { return "", fail }
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||
t.Fatalf("embedded mkdir error = %v", err)
|
||||
}
|
||||
embeddedSkillMkdirTemp = func(string, string) (string, error) { return t.TempDir(), nil }
|
||||
removed := false
|
||||
embeddedSkillRemoveAll = func(string) error { removed = true; return nil }
|
||||
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||
return fn("entry", nil, fail)
|
||||
}
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) || !removed {
|
||||
t.Fatalf("embedded walk error = %v, removed=%v", err, removed)
|
||||
}
|
||||
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
|
||||
}
|
||||
embeddedSkillReadFile = func(string) ([]byte, error) { return nil, fail }
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||
t.Fatalf("embedded read error = %v", err)
|
||||
}
|
||||
embeddedSkillReadFile = func(string) ([]byte, error) { return []byte("skill"), nil }
|
||||
embeddedSkillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||
t.Fatalf("embedded nested mkdir error = %v", err)
|
||||
}
|
||||
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||
return fn("skills/codex/dir", fakeSkillDirEntry{dir: true}, nil)
|
||||
}
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||
t.Fatalf("embedded directory mkdir error = %v", err)
|
||||
}
|
||||
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
|
||||
}
|
||||
embeddedSkillMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
embeddedSkillWriteFile = func(string, []byte, os.FileMode) error { return fail }
|
||||
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||
t.Fatalf("embedded write error = %v", err)
|
||||
}
|
||||
|
||||
merged := mergeTopLevelCommands([]*cobra.Command{nil, {}})
|
||||
if len(merged) != 0 {
|
||||
t.Fatalf("empty legacy commands = %#v", merged)
|
||||
}
|
||||
root := &cobra.Command{Use: "root"}
|
||||
completion := newCompletionCommand(root)
|
||||
if err := completion.RunE(completion, []string{"other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := newCatalogCommand(nil)
|
||||
catalog.SetOut(io.Discard)
|
||||
if err := catalog.RunE(catalog, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
auditCSVWrite = func(writer *csv.Writer, record []string) error { return writer.Write(record) }
|
||||
auditCSVFlush = func(writer *csv.Writer) { writer.Flush() }
|
||||
auditCSVError = func(writer *csv.Writer) error { return writer.Error() }
|
||||
auditExit = os.Exit
|
||||
auditVerify = audit.VerifyFile
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
@@ -109,7 +117,7 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
valid, brokenAt, err := audit.VerifyFile(target)
|
||||
valid, brokenAt, err := auditVerify(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验失败: %w", err)
|
||||
}
|
||||
@@ -117,7 +125,7 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
|
||||
} else {
|
||||
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
|
||||
os.Exit(1)
|
||||
auditExit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -179,7 +187,7 @@ func exportCSV(files []string) error {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
|
||||
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
|
||||
if err := w.Write(header); err != nil {
|
||||
if err := auditCSVWrite(w, header); err != nil {
|
||||
return fmt.Errorf("写入 CSV 表头失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -213,7 +221,7 @@ func exportCSV(files []string) error {
|
||||
strconv.FormatInt(evt.DurationMs, 10),
|
||||
evt.ErrCategory,
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
if err := auditCSVWrite(w, row); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("写入 CSV 记录失败: %w", err)
|
||||
}
|
||||
@@ -225,8 +233,8 @@ func exportCSV(files []string) error {
|
||||
f.Close()
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
auditCSVFlush(w)
|
||||
if err := auditCSVError(w); err != nil {
|
||||
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"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/logging"
|
||||
)
|
||||
|
||||
type auditCoverageSink struct {
|
||||
events []*audit.Event
|
||||
emitErr error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (sink *auditCoverageSink) Emit(event *audit.Event) error {
|
||||
sink.events = append(sink.events, event)
|
||||
return sink.emitErr
|
||||
}
|
||||
|
||||
func (sink *auditCoverageSink) Close() error { return sink.closeErr }
|
||||
|
||||
func TestCrossPlatformCoverageAuditCommandsAndFileHelpersCoverage(t *testing.T) {
|
||||
originalExit, originalVerify := auditExit, auditVerify
|
||||
t.Cleanup(func() { auditExit, auditVerify = originalExit, originalVerify })
|
||||
dir := t.TempDir()
|
||||
t.Setenv(audit.EnvAuditDir, dir)
|
||||
if auditDir() != dir {
|
||||
t.Fatalf("auditDir() = %q", auditDir())
|
||||
}
|
||||
|
||||
tail := newAuditTailCommand()
|
||||
tail.SetArgs([]string{"--lines", "1"})
|
||||
if err := tail.Execute(); err == nil || !strings.Contains(err.Error(), "无审计记录") {
|
||||
t.Fatalf("audit tail(empty) error = %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
if err := os.WriteFile(path, []byte("one\ntwo\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tail = newAuditTailCommand()
|
||||
tail.SetArgs([]string{"--lines", "1"})
|
||||
if err := tail.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tailFile(filepath.Join(dir, "missing"), 1); err == nil {
|
||||
t.Fatal("tailFile(missing) error = nil")
|
||||
}
|
||||
tailErrorDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(tailErrorDir, "audit-20260101.jsonl"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(audit.EnvAuditDir, tailErrorDir)
|
||||
tail = newAuditTailCommand()
|
||||
if err := tail.Execute(); err == nil {
|
||||
t.Fatal("audit tail(directory record) error = nil")
|
||||
}
|
||||
oversize := filepath.Join(dir, "oversize")
|
||||
if err := os.WriteFile(oversize, []byte(strings.Repeat("x", 2*1024*1024)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tailFile(oversize, 1); err == nil {
|
||||
t.Fatal("tailFile(oversize) error = nil")
|
||||
}
|
||||
|
||||
exportDir := t.TempDir()
|
||||
t.Setenv(audit.EnvAuditDir, exportDir)
|
||||
export := newAuditExportCommand()
|
||||
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
|
||||
t.Fatalf("audit export(empty) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(exportDir, "audit-20260102.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, format := range []string{"jsonl", "csv"} {
|
||||
export = newAuditExportCommand()
|
||||
export.SetArgs([]string{"--since", "2026-01-01", "--until", "2026-01-03", "--format", format})
|
||||
if err := export.Execute(); err != nil {
|
||||
t.Fatalf("audit export(%s) error = %v", format, err)
|
||||
}
|
||||
}
|
||||
export = newAuditExportCommand()
|
||||
export.SetArgs([]string{"--format", "xml"})
|
||||
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "不支持的格式") {
|
||||
t.Fatalf("audit export(xml) error = %v", err)
|
||||
}
|
||||
t.Setenv(audit.EnvAuditDir, filepath.Join(exportDir, "missing"))
|
||||
export = newAuditExportCommand()
|
||||
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "查找审计文件失败") {
|
||||
t.Fatalf("audit export(missing dir) error = %v", err)
|
||||
}
|
||||
|
||||
if err := exportJSONL([]string{filepath.Join(dir, "missing")}); err == nil {
|
||||
t.Fatal("exportJSONL(missing) error = nil")
|
||||
}
|
||||
if err := exportJSONL([]string{oversize}); err == nil {
|
||||
t.Fatal("exportJSONL(oversize) error = nil")
|
||||
}
|
||||
if err := exportCSV([]string{filepath.Join(dir, "missing")}); err == nil {
|
||||
t.Fatal("exportCSV(missing) error = nil")
|
||||
}
|
||||
blank := filepath.Join(dir, "blank")
|
||||
if err := os.WriteFile(blank, []byte("\n \n{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := exportCSV([]string{blank}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := exportCSV([]string{oversize}); err == nil {
|
||||
t.Fatal("exportCSV(oversize) error = nil")
|
||||
}
|
||||
|
||||
originalWrite, originalFlush, originalError := auditCSVWrite, auditCSVFlush, auditCSVError
|
||||
t.Cleanup(func() { auditCSVWrite, auditCSVFlush, auditCSVError = originalWrite, originalFlush, originalError })
|
||||
auditCSVWrite = func(*csv.Writer, []string) error { return errors.New("write") }
|
||||
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "表头") {
|
||||
t.Fatalf("exportCSV(header error) = %v", err)
|
||||
}
|
||||
calls := 0
|
||||
auditCSVWrite = func(writer *csv.Writer, row []string) error {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return errors.New("row")
|
||||
}
|
||||
return originalWrite(writer, row)
|
||||
}
|
||||
if err := exportCSV([]string{blank}); err == nil || !strings.Contains(err.Error(), "记录") {
|
||||
t.Fatalf("exportCSV(row error) = %v", err)
|
||||
}
|
||||
auditCSVWrite = originalWrite
|
||||
auditCSVError = func(*csv.Writer) error { return errors.New("flush") }
|
||||
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "刷新") {
|
||||
t.Fatalf("exportCSV(flush error) = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(audit.EnvAuditDir, t.TempDir())
|
||||
verify := newAuditVerifyCommand()
|
||||
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
|
||||
t.Fatalf("audit verify(empty) error = %v", err)
|
||||
}
|
||||
verify = newAuditVerifyCommand()
|
||||
verify.SetArgs([]string{"--file", filepath.Join(dir, "missing")})
|
||||
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "校验失败") {
|
||||
t.Fatalf("audit verify(missing) error = %v", err)
|
||||
}
|
||||
validDir := t.TempDir()
|
||||
writer, err := audit.NewDateRotatingWriter(validDir, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sink := audit.NewFileSink(writer, audit.NewChain(validDir), nil)
|
||||
if err := sink.Emit(&audit.Event{Timestamp: time.Now(), Product: "test", Command: "ok"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validFile, err := audit.LatestAuditFile(validDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verify = newAuditVerifyCommand()
|
||||
verify.SetArgs([]string{"--file", validFile})
|
||||
if err := verify.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := filepath.Join(t.TempDir(), "audit-broken.jsonl")
|
||||
if err := os.WriteFile(broken, []byte(`{"prev_hash":"wrong","hash":"wrong"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exitCode := 0
|
||||
auditExit = func(code int) { exitCode = code }
|
||||
auditVerify = func(string) (bool, int, error) { return false, 1, nil }
|
||||
verify = newAuditVerifyCommand()
|
||||
verify.SetArgs([]string{"--file", broken})
|
||||
if err := verify.Execute(); err != nil || exitCode != 1 {
|
||||
t.Fatalf("audit verify(broken) = %v, exit=%d", err, exitCode)
|
||||
}
|
||||
t.Setenv(audit.EnvAuditDir, "")
|
||||
if auditDir() == "" {
|
||||
t.Fatal("default auditDir() is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
|
||||
previousSink, previousLoader := sharedAuditSink, loadTokenForProfile
|
||||
t.Cleanup(func() {
|
||||
sharedAuditSink = previousSink
|
||||
loadTokenForProfile = previousLoader
|
||||
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
|
||||
sharedAuditSink = nil
|
||||
auditCloseOnce = sync.Once{}
|
||||
CloseAuditSink()
|
||||
failedClose := &auditCoverageSink{closeErr: errors.New("close")}
|
||||
sharedAuditSink = failedClose
|
||||
auditCloseOnce = sync.Once{}
|
||||
CloseAuditSink()
|
||||
|
||||
bad := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(bad, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(audit.EnvAudit, "1")
|
||||
t.Setenv(audit.EnvAuditDir, filepath.Join(bad, "child"))
|
||||
auditSinkOnce = sync.Once{}
|
||||
sharedAuditSink = nil
|
||||
if _, ok := setupAuditSink().(audit.NopSink); !ok {
|
||||
t.Fatalf("setupAuditSink(error) = %T", sharedAuditSink)
|
||||
}
|
||||
|
||||
t.Setenv(audit.EnvAuditDebug, "1")
|
||||
fileLogger = logging.Setup(t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
if fileLogger != nil {
|
||||
fileLogger.Close()
|
||||
fileLogger = nil
|
||||
}
|
||||
})
|
||||
auditReport("coverage %d", 1)
|
||||
loadTokenForProfile = func(string, string) (*auth.TokenData, error) { return nil, errors.New("identity") }
|
||||
resetAuditIdentityCache()
|
||||
if actor, _ := auditIdentity(); actor.UserID != "" {
|
||||
t.Fatalf("auditIdentity(error) = %+v", actor)
|
||||
}
|
||||
|
||||
invocation := executor.Invocation{CanonicalProduct: "calendar", Tool: "list", Params: map[string]any{"token": "secret"}}
|
||||
emitAudit(nil, "nil", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
|
||||
emitAudit(audit.NopSink{}, "nop", time.Now(), invocation, "", nil, "test")
|
||||
recording := &auditCoverageSink{}
|
||||
emitAudit(recording, "ok", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
|
||||
if len(recording.events) != 1 || recording.events[0].Result != "success" {
|
||||
t.Fatalf("successful audit events = %#v", recording.events)
|
||||
}
|
||||
typed := &apperrors.Error{Category: apperrors.CategoryAuth, Reason: "expired"}
|
||||
emitAudit(recording, "typed", time.Now(), invocation, "", typed, "test")
|
||||
if recording.events[1].ErrReason != "expired" {
|
||||
t.Fatalf("typed audit event = %#v", recording.events[1])
|
||||
}
|
||||
recording.emitErr = errors.New("emit")
|
||||
emitAudit(recording, "failed", time.Now(), invocation, "", errors.New("plain"), "test")
|
||||
if category, reason := classifyAuditError(nil); category != "" || reason != "" {
|
||||
t.Fatalf("classifyAuditError(nil) = %q, %q", category, reason)
|
||||
}
|
||||
if category, reason := classifyAuditError(fmt.Errorf("wrapped: %w", typed)); category != string(apperrors.CategoryAuth) || reason != "expired" {
|
||||
t.Fatalf("classifyAuditError(typed) = %q, %q", category, reason)
|
||||
}
|
||||
if category, reason := classifyAuditError(errors.New("plain")); category != "unknown" || reason != "plain" {
|
||||
t.Fatalf("classifyAuditError(plain) = %q, %q", category, reason)
|
||||
}
|
||||
}
|
||||
+119
-69
@@ -136,7 +136,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
AccessToken: cfg.Token,
|
||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||
}
|
||||
case cfg.Device:
|
||||
@@ -146,7 +146,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
tokenData, err = provider.Login(loginCtx)
|
||||
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
||||
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
if postLoginTUIMode {
|
||||
var planErr error
|
||||
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
|
||||
initialPlan, planErr = authPlanLoginRecommend(cmd.Context(), patCaller)
|
||||
if planErr != nil {
|
||||
return planErr
|
||||
}
|
||||
@@ -207,11 +207,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
retryFormat = "table"
|
||||
}
|
||||
run := func(ctx context.Context) error {
|
||||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||
return authRunLoginRecommend(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||
}
|
||||
err := run(cmd.Context())
|
||||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||||
return runDirectPATAuthCheckWaitOnly(
|
||||
return authRunDirectPATWait(
|
||||
cmd.Context(),
|
||||
&GlobalFlags{Format: retryFormat},
|
||||
patErr,
|
||||
@@ -234,11 +234,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
}
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||||
@@ -279,12 +275,50 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
authLoginGuideActionSelector = selectAuthLoginGuideAction
|
||||
authLoginGuideActionApplier = applyAuthLoginGuideAction
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
authLoginGuideActionSelector = selectAuthLoginGuideAction
|
||||
authLoginGuideActionApplier = applyAuthLoginGuideAction
|
||||
authLoginManualCredentialsPrompt = promptAuthLoginManualCredentials
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") }
|
||||
authRunForm = (*huh.Form).Run
|
||||
authSaveTokenData = authpkg.SaveTokenData
|
||||
authSaveAppConfig = authpkg.SaveAppConfig
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||
return provider.Login(ctx)
|
||||
}
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, force bool) (*authpkg.TokenData, error) {
|
||||
return provider.Login(ctx, force)
|
||||
}
|
||||
authOAuthStatus = func(provider *authpkg.OAuthProvider) (*authpkg.TokenData, error) { return provider.Status() }
|
||||
authOAuthAccessToken = func(provider *authpkg.OAuthProvider, ctx context.Context) (string, error) {
|
||||
return provider.GetAccessToken(ctx)
|
||||
}
|
||||
authOAuthExchange = func(provider *authpkg.OAuthProvider, ctx context.Context, code, uid string) (*authpkg.TokenData, error) {
|
||||
return provider.ExchangeAuthCode(ctx, code, uid)
|
||||
}
|
||||
authPlanLoginRecommend = pat.PlanLoginRecommendAuthorization
|
||||
authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions
|
||||
authRunDirectPATWait = runDirectPATAuthCheckWaitOnly
|
||||
authResolveProfile = authpkg.ResolveProfile
|
||||
authRevokeToken = authpkg.RevokeTokenRemote
|
||||
authDeleteProfileToken = authpkg.DeleteTokenDataForProfile
|
||||
authEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||
authLoadProfiles = authpkg.LoadProfiles
|
||||
authDeleteAllTokenData = authpkg.DeleteAllTokenData
|
||||
authDeleteTokenData = authpkg.DeleteTokenData
|
||||
authMarkProfileStatus = authpkg.MarkProfileStatus
|
||||
authPortableExportSupported = authpkg.PortableExportSupported
|
||||
authPortableSourceReady = authpkg.PortableAuthSourceReady
|
||||
authPortableTargetPopulated = authpkg.PortableAuthTargetPopulated
|
||||
authExportPortableBundle = authpkg.ExportPortableAuthBundle
|
||||
authImportPortableBundle = authpkg.ImportPortableAuthBundle
|
||||
authAtomicWrite = helpers.AtomicWrite
|
||||
authReadFile = os.ReadFile
|
||||
authRemove = os.Remove
|
||||
authDeleteAppConfig = authpkg.DeleteAppConfig
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -301,7 +335,7 @@ func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
if err := authRunForm(form); err != nil {
|
||||
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
@@ -315,13 +349,13 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
|
||||
return nil
|
||||
case authLoginGuideManualCredentials:
|
||||
clientID, clientSecret, err := promptAuthLoginManualCredentials()
|
||||
clientID, clientSecret, err := authLoginManualCredentialsPrompt()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authpkg.SetClientID(clientID)
|
||||
authpkg.SetClientSecret(clientSecret)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
if err := authSaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: clientID,
|
||||
ClientSecret: authpkg.PlainSecret(clientSecret),
|
||||
}); err != nil {
|
||||
@@ -335,33 +369,34 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
|
||||
|
||||
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")),
|
||||
Validate(authLoginNonEmpty("AppKey")),
|
||||
huh.NewInput().
|
||||
Title("输入 AppSecret").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&clientSecret).
|
||||
Validate(nonEmpty("AppSecret")),
|
||||
Validate(authLoginNonEmpty("AppSecret")),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
if err := authRunForm(form); err != nil {
|
||||
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
||||
}
|
||||
|
||||
func authLoginNonEmpty(label string) func(string) error {
|
||||
return func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s 不能为空", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
choice := pat.LoginRecommendScopeRecommended
|
||||
form := huh.NewForm(
|
||||
@@ -376,7 +411,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
if err := authRunForm(form); err != nil {
|
||||
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
@@ -451,21 +486,21 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
var statusErr error
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := provider.Status(); err == nil {
|
||||
if data, err := authOAuthStatus(provider); err == nil {
|
||||
tokenData = data
|
||||
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
|
||||
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||||
_, refreshErr := authOAuthAccessToken(provider, refreshCtx)
|
||||
cancel()
|
||||
if refreshErr == nil {
|
||||
if updatedData, statusErr := provider.Status(); statusErr == nil {
|
||||
if updatedData, statusErr := authOAuthStatus(provider); statusErr == nil {
|
||||
tokenData = updatedData
|
||||
refreshed = true
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
_ = authDeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
_ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -536,7 +571,7 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
target, err := authMigrateTarget(cmd)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
@@ -583,36 +618,36 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
if _, err := authResolveProfile(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 {
|
||||
_ = authRevokeToken(ctx)
|
||||
if err := authDeleteProfileToken(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 {
|
||||
if err := authEnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
cfg, err := authLoadProfiles(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)
|
||||
_ = authRevokeToken(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
_ = authRevokeToken(ctx)
|
||||
restoreProfile()
|
||||
}
|
||||
}
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
if err := authDeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
@@ -631,6 +666,10 @@ func pushRuntimeProfile(selector string) func() {
|
||||
}
|
||||
|
||||
func newAuthExportCommand() *cobra.Command {
|
||||
return newAuthExportCommandWithSupport(authpkg.PortableExportSupportError)
|
||||
}
|
||||
|
||||
func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出可迁移认证包",
|
||||
@@ -655,18 +694,21 @@ func newAuthExportCommand() *cobra.Command {
|
||||
if !asBase64 && output == "" {
|
||||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
if err := supportError(); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if !authPortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
if !authpkg.PortableAuthSourceReady() {
|
||||
if !authPortableSourceReady() {
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
|
||||
if err := authExportPortableBundle(defaultConfigDir(), &bundle); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
||||
}
|
||||
|
||||
@@ -676,7 +718,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
_, err := cmd.OutOrStdout().Write(payload)
|
||||
return err
|
||||
}
|
||||
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
|
||||
if err := authAtomicWrite(output, payload, config.FilePerm); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||
@@ -684,7 +726,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||||
if err := authAtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||
@@ -698,6 +740,10 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newAuthImportCommand() *cobra.Command {
|
||||
return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError)
|
||||
}
|
||||
|
||||
func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "导入可迁移认证包",
|
||||
@@ -726,13 +772,15 @@ func newAuthImportCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
|
||||
if err := supportError(); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
if !force && authpkg.PortableAuthTargetPopulated(configDir) {
|
||||
if !force && authPortableTargetPopulated(configDir) {
|
||||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||||
}
|
||||
|
||||
payload, err := os.ReadFile(input)
|
||||
payload, err := authReadFile(input)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
||||
}
|
||||
@@ -742,7 +790,7 @@ func newAuthImportCommand() *cobra.Command {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
||||
}
|
||||
}
|
||||
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
|
||||
report, err := authImportPortableBundle(configDir, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
||||
}
|
||||
@@ -787,7 +835,7 @@ func newAuthExchangeCommand() *cobra.Command {
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
||||
defer cancel()
|
||||
tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid))
|
||||
tokenData, err := authOAuthExchange(provider, exchangeCtx, code, strings.TrimSpace(uid))
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||||
}
|
||||
@@ -826,12 +874,12 @@ func newAuthResetCommand() *cobra.Command {
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
if err := authDeleteAllTokenData(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)
|
||||
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = authRemove(filepath.Join(configDir, "token"))
|
||||
_ = authDeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -907,20 +955,22 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin
|
||||
Options(options...).
|
||||
Height(height).
|
||||
Value(&selected).
|
||||
Validate(func(values []string) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("至少选择一个授权业务域")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
Validate(authLoginProductsNonEmpty),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
if err := authRunForm(form); err != nil {
|
||||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func authLoginProductsNonEmpty(values []string) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("至少选择一个授权业务域")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLoginHuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
@@ -1123,7 +1173,7 @@ func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
}
|
||||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
@@ -1182,7 +1232,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
return nil
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
||||
if err := authSaveTokenData(configDir, &updated); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = updated
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/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/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type authCoverageCaller struct {
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result, c.err
|
||||
}
|
||||
func (*authCoverageCaller) Format() string { return "json" }
|
||||
func (*authCoverageCaller) DryRun() bool { return false }
|
||||
func (*authCoverageCaller) Fields() string { return "" }
|
||||
func (*authCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func authCoverageRoot(child *cobra.Command, format string, yes bool) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetContext(context.Background())
|
||||
child.SetContext(context.Background())
|
||||
root.PersistentFlags().String("format", format, "")
|
||||
root.PersistentFlags().Bool("yes", yes, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.AddCommand(child)
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
root.SetOut(out)
|
||||
root.SetErr(errOut)
|
||||
return root, out, errOut
|
||||
}
|
||||
|
||||
func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string, yes bool, flags map[string]string) (string, string, error) {
|
||||
t.Helper()
|
||||
cmd := newAuthLoginCommand(caller)
|
||||
root, out, errOut := authCoverageRoot(cmd, format, yes)
|
||||
for name, value := range flags {
|
||||
flagSet := cmd.Flags()
|
||||
if root.PersistentFlags().Lookup(name) != nil {
|
||||
flagSet = root.PersistentFlags()
|
||||
}
|
||||
if err := flagSet.Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldRunForm := authRunForm
|
||||
oldPrompt := authLoginManualCredentialsPrompt
|
||||
oldSaveConfig := authSaveAppConfig
|
||||
oldResolve := authResolveProfile
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authRunForm = oldRunForm
|
||||
authLoginManualCredentialsPrompt = oldPrompt
|
||||
authSaveAppConfig = oldSaveConfig
|
||||
authResolveProfile = oldResolve
|
||||
})
|
||||
|
||||
edition.Override(&edition.Hooks{})
|
||||
parent := buildAuthCommand(nil)
|
||||
if parent.CommandPath() == "" || parent.RunE(parent, nil) != nil {
|
||||
t.Fatal("auth parent should render help")
|
||||
}
|
||||
edition.Override(&edition.Hooks{HideAuthLogin: true})
|
||||
if got := buildAuthCommand(nil).Commands(); len(got) != 7 {
|
||||
t.Fatalf("hidden-login subcommands = %d, want 7", len(got))
|
||||
}
|
||||
|
||||
authRunForm = func(*huh.Form) error { return nil }
|
||||
if choice, err := selectAuthLoginGuideAction(); err != nil || choice != authLoginGuideDirectCLI {
|
||||
t.Fatalf("guide choice = %q, %v", choice, err)
|
||||
}
|
||||
if id, secret, err := promptAuthLoginManualCredentials(); err != nil || id != "" || secret != "" {
|
||||
t.Fatalf("manual prompt = %q/%q, %v", id, secret, err)
|
||||
}
|
||||
if mode, err := selectLoginRecommendScopeMode(); err != nil || mode != pat.LoginRecommendScopeRecommended {
|
||||
t.Fatalf("scope mode = %q, %v", mode, err)
|
||||
}
|
||||
products := []pat.LoginRecommendProduct{{ProductCode: "doc"}, {ProductCode: ""}}
|
||||
if selected, err := selectLoginRecommendProducts(products); err != nil || len(selected) != 1 || selected[0] != "doc" {
|
||||
t.Fatalf("selected products = %#v, %v", selected, err)
|
||||
}
|
||||
if selected, err := selectLoginRecommendProducts(nil); err != nil || selected != nil {
|
||||
t.Fatalf("empty selected products = %#v, %v", selected, err)
|
||||
}
|
||||
if selected, err := selectLoginRecommendProducts([]pat.LoginRecommendProduct{{}}); err != nil || selected != nil {
|
||||
t.Fatalf("blank selected products = %#v, %v", selected, err)
|
||||
}
|
||||
many := make([]pat.LoginRecommendProduct, 16)
|
||||
for i := range many {
|
||||
many[i].ProductCode = fmt.Sprintf("p%d", i)
|
||||
}
|
||||
if selected, err := selectLoginRecommendProducts(many); err != nil || len(selected) != 16 {
|
||||
t.Fatalf("many selected products = %d, %v", len(selected), err)
|
||||
}
|
||||
if authLoginProductsNonEmpty(nil) == nil || authLoginProductsNonEmpty([]string{"doc"}) != nil {
|
||||
t.Fatal("product validator mismatch")
|
||||
}
|
||||
|
||||
authRunForm = func(*huh.Form) error { return errors.New("cancel") }
|
||||
if _, err := selectAuthLoginGuideAction(); err == nil {
|
||||
t.Fatal("guide cancellation should fail")
|
||||
}
|
||||
if _, _, err := promptAuthLoginManualCredentials(); err == nil {
|
||||
t.Fatal("credential cancellation should fail")
|
||||
}
|
||||
if _, err := selectLoginRecommendScopeMode(); err == nil {
|
||||
t.Fatal("scope cancellation should fail")
|
||||
}
|
||||
if _, err := selectLoginRecommendProducts(products); err == nil {
|
||||
t.Fatal("product cancellation should fail")
|
||||
}
|
||||
if authLoginNonEmpty("field")(" ") == nil || authLoginNonEmpty("field")("value") != nil {
|
||||
t.Fatal("non-empty validator mismatch")
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideDirectCLI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideConfigureAgentApp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), "unknown"); err == nil {
|
||||
t.Fatal("unknown guide action should fail")
|
||||
}
|
||||
authLoginManualCredentialsPrompt = func() (string, string, error) { return "", "", errors.New("cancel") }
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
|
||||
t.Fatal("manual prompt error should propagate")
|
||||
}
|
||||
authLoginManualCredentialsPrompt = func() (string, string, error) { return "id", "secret", nil }
|
||||
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return errors.New("save") }
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
|
||||
t.Fatal("app-config save error should propagate")
|
||||
}
|
||||
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
|
||||
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
|
||||
return &authpkg.Profile{CorpID: " ding-profile "}, nil
|
||||
}
|
||||
if got, err := resolveAuthLoginTargetCorpID("cfg", "name"); err != nil || got != "ding-profile" {
|
||||
t.Fatalf("resolved target = %q, %v", got, err)
|
||||
}
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||
for selector, want := range map[string]struct {
|
||||
value string
|
||||
err bool
|
||||
}{"": {"", false}, "ding-direct": {"ding-direct", false}, "other": {"", true}} {
|
||||
got, err := resolveAuthLoginTargetCorpID("cfg", selector)
|
||||
if got != want.value || (err != nil) != want.err {
|
||||
t.Fatalf("target %q = %q, %v", selector, got, err)
|
||||
}
|
||||
}
|
||||
badToken := &cobra.Command{}
|
||||
badToken.Flags().Bool("token", false, "")
|
||||
if _, err := resolveAuthLoginConfig(badToken); err == nil {
|
||||
t.Fatal("invalid token flag should fail")
|
||||
}
|
||||
badDevice := &cobra.Command{}
|
||||
badDevice.Flags().String("token", "", "")
|
||||
badDevice.Flags().String("device", "", "")
|
||||
if _, err := resolveAuthLoginConfig(badDevice); err == nil {
|
||||
t.Fatal("invalid device flag should fail")
|
||||
}
|
||||
badForce := &cobra.Command{}
|
||||
badForce.Flags().String("token", "", "")
|
||||
badForce.Flags().Bool("device", false, "")
|
||||
badForce.Flags().String("force", "", "")
|
||||
if _, err := resolveAuthLoginConfig(badForce); err == nil {
|
||||
t.Fatal("invalid force flag should fail")
|
||||
}
|
||||
badRecommend := &cobra.Command{}
|
||||
badRecommend.Flags().String("token", "", "")
|
||||
badRecommend.Flags().Bool("device", false, "")
|
||||
badRecommend.Flags().Bool("force", false, "")
|
||||
badRecommend.Flags().String("recommend", "", "")
|
||||
if _, err := resolveAuthLoginConfig(badRecommend); err == nil {
|
||||
t.Fatal("invalid recommend flag should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldSave := authSaveTokenData
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
oldRunRecommend := authRunLoginRecommend
|
||||
oldRunWait := authRunDirectPATWait
|
||||
oldPlan := authPlanLoginRecommend
|
||||
oldScope := loginRecommendScopeModeSelector
|
||||
oldProducts := loginRecommendProductSelector
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
oldResolve := authResolveProfile
|
||||
t.Cleanup(func() {
|
||||
authSaveTokenData = oldSave
|
||||
authDeviceLogin = oldDevice
|
||||
authOAuthLogin = oldOAuth
|
||||
authRunLoginRecommend = oldRunRecommend
|
||||
authRunDirectPATWait = oldRunWait
|
||||
authPlanLoginRecommend = oldPlan
|
||||
loginRecommendScopeModeSelector = oldScope
|
||||
loginRecommendProductSelector = oldProducts
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
authResolveProfile = oldResolve
|
||||
})
|
||||
authInteractiveFalse := func() bool { return false }
|
||||
authLoginInteractiveTerminal = authInteractiveFalse
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"profile": "bad"}); err == nil {
|
||||
t.Fatal("invalid login profile should fail")
|
||||
}
|
||||
|
||||
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token"}); err == nil {
|
||||
t.Fatal("token save should fail")
|
||||
}
|
||||
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": " token "}); err != nil || !strings.Contains(out, "登录成功") {
|
||||
t.Fatalf("token login = %q, %v", out, err)
|
||||
}
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||
t.Fatalf("json token login = %q, %v", out, err)
|
||||
}
|
||||
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("device")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
||||
t.Fatal("device error should propagate")
|
||||
}
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("oauth")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
|
||||
t.Fatal("oauth error should propagate")
|
||||
}
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
|
||||
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
|
||||
}, nil
|
||||
}
|
||||
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{}`}}}}
|
||||
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||
t.Fatalf("oauth success = %q, %v", out, err)
|
||||
}
|
||||
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return errors.New("recommend")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
|
||||
t.Fatal("recommend error should propagate")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
|
||||
t.Fatal("JSON recommend error should propagate")
|
||||
}
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return &apperrors.PATError{RawJSON: `{"code":"PAT_SCOPE_AUTH_REQUIRED"}`}
|
||||
}
|
||||
waited := false
|
||||
authRunDirectPATWait = func(context.Context, *GlobalFlags, *apperrors.PATError, io.Writer) error {
|
||||
waited = true
|
||||
return nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err != nil || !waited {
|
||||
t.Fatalf("PAT wait = %v, waited=%v", err, waited)
|
||||
}
|
||||
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||
return nil, errors.New("plan")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
|
||||
t.Fatal("plan error should propagate")
|
||||
}
|
||||
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||
return &pat.LoginRecommendPlan{AllGranted: true}, nil
|
||||
}
|
||||
if _, stderr, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !strings.Contains(stderr, "全部授权") {
|
||||
t.Fatalf("all-granted plan = %q, %v", stderr, err)
|
||||
}
|
||||
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||
return &pat.LoginRecommendPlan{Scopes: []string{"scope"}, Products: []pat.LoginRecommendProduct{{ProductCode: "doc"}}}, nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return "", errors.New("scope") }
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
|
||||
t.Fatal("scope selector error should propagate")
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return pat.LoginRecommendScopeAll, nil }
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { return []string{"doc"}, nil }
|
||||
selected := false
|
||||
authRunLoginRecommend = func(_ context.Context, _ edition.ToolCaller, _ io.Writer, opts pat.LoginRecommendOptions) error {
|
||||
if opts.ProductSelector != nil {
|
||||
_, err := opts.ProductSelector(opts.InitialPlan.Products)
|
||||
selected = err == nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !selected {
|
||||
t.Fatalf("interactive recommendation = %v, selected=%v", err, selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
oldSave := authSaveTokenData
|
||||
t.Cleanup(func() { authSaveTokenData = oldSave })
|
||||
ctx := context.Background()
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, &authpkg.TokenData{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
complete := &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u", UserName: "User"}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||
t.Fatal("caller error should propagate")
|
||||
}
|
||||
for _, text := range []string{"", "not-json", `{"result":[]}`, `{"result":[{"orgEmployeeModel":{}}]}`} {
|
||||
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: text}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding"}); err != nil {
|
||||
t.Fatalf("invalid contact %q: %v", text, err)
|
||||
}
|
||||
}
|
||||
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||
t.Fatal("corp mismatch should fail")
|
||||
}
|
||||
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unchangedPartial := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", unchangedPartial, &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||
t.Fatal("enrichment save should fail")
|
||||
}
|
||||
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||
data := &authpkg.TokenData{CorpID: "ding"}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||
t.Fatalf("enriched = %#v, %v", data, err)
|
||||
}
|
||||
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
|
||||
t.Fatal("nil result should not parse")
|
||||
}
|
||||
if got := firstNonEmptyString(" ", " value ", "later"); got != "value" {
|
||||
t.Fatalf("first non-empty = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
configDir := t.TempDir()
|
||||
device := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
_, _ = authDeviceLogin(device, ctx)
|
||||
oauth := authpkg.NewOAuthProvider(configDir, nil)
|
||||
_, _ = authOAuthStatus(oauth)
|
||||
_, _ = authOAuthAccessToken(oauth, ctx)
|
||||
_, _ = authOAuthLogin(oauth, ctx, true)
|
||||
_, _ = authOAuthExchange(oauth, ctx, "code", "uid")
|
||||
_ = fmt.Sprintf("%v", os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageStatusAndLogout(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldStatus := authOAuthStatus
|
||||
oldAccess := authOAuthAccessToken
|
||||
oldDelete := authDeleteTokenData
|
||||
oldMark := authMarkProfileStatus
|
||||
oldResolve := authResolveProfile
|
||||
oldRevoke := authRevokeToken
|
||||
oldDeleteProfile := authDeleteProfileToken
|
||||
oldMigrate := authEnsureProfilesMigration
|
||||
oldLoadProfiles := authLoadProfiles
|
||||
oldDeleteAll := authDeleteAllTokenData
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authOAuthStatus = oldStatus
|
||||
authOAuthAccessToken = oldAccess
|
||||
authDeleteTokenData = oldDelete
|
||||
authMarkProfileStatus = oldMark
|
||||
authResolveProfile = oldResolve
|
||||
authRevokeToken = oldRevoke
|
||||
authDeleteProfileToken = oldDeleteProfile
|
||||
authEnsureProfilesMigration = oldMigrate
|
||||
authLoadProfiles = oldLoadProfiles
|
||||
authDeleteAllTokenData = oldDeleteAll
|
||||
})
|
||||
|
||||
badStatus := newAuthStatusCommand()
|
||||
bad := &cobra.Command{}
|
||||
bad.Flags().Bool("profile", false, "")
|
||||
if err := badStatus.RunE(bad, nil); err == nil {
|
||||
t.Fatal("invalid profile flag should fail")
|
||||
}
|
||||
|
||||
runStatus := func(format string) (string, error) {
|
||||
cmd := newAuthStatusCommand()
|
||||
_, out, _ := authCoverageRoot(cmd, format, false)
|
||||
err := cmd.RunE(cmd, nil)
|
||||
return out.String(), err
|
||||
}
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "auth login") {
|
||||
t.Fatalf("plain unauthenticated status = %q, %v", out, err)
|
||||
}
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||
return nil, keychain.NewUnavailableError("read", errors.New("status"))
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "未登录") {
|
||||
t.Fatalf("status error = %q, %v", out, err)
|
||||
}
|
||||
if out, err := runStatus("json"); err != nil || !strings.Contains(out, `"authenticated": false`) {
|
||||
t.Fatalf("json status error = %q, %v", out, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour), CorpID: "ding", CorpName: "Corp"}
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "已登录") || !strings.Contains(out, "缺失或已过期") {
|
||||
t.Fatalf("valid access status = %q, %v", out, err)
|
||||
}
|
||||
valid.RefreshToken = "r"
|
||||
valid.RefreshExpAt = now.Add(time.Hour)
|
||||
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "Refresh Token:") {
|
||||
t.Fatalf("valid refresh status = %q, %v", out, err)
|
||||
}
|
||||
|
||||
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
|
||||
updated := &authpkg.TokenData{AccessToken: "new", ExpiresAt: now.Add(time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
|
||||
calls := 0
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return expired, nil
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "new", nil }
|
||||
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "自动刷新") {
|
||||
t.Fatalf("refreshed status = %q, %v", out, err)
|
||||
}
|
||||
calls = 0
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return expired, nil
|
||||
}
|
||||
return nil, errors.New("second status")
|
||||
}
|
||||
if _, err := runStatus("table"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
|
||||
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
|
||||
deleted := false
|
||||
marked := false
|
||||
authDeleteTokenData = func(string) error { deleted = true; return errors.New("ignored") }
|
||||
authMarkProfileStatus = func(string, string, string) error { marked = true; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{AutoPurgeToken: true})
|
||||
if _, err := runStatus("table"); err != nil || !deleted {
|
||||
t.Fatalf("auto-purge = %v, deleted=%v", err, deleted)
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if _, err := runStatus("table"); err != nil || !marked {
|
||||
t.Fatalf("mark-expired = %v, marked=%v", err, marked)
|
||||
}
|
||||
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
|
||||
t.Fatal("missing profile should fail")
|
||||
}
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "ding"}, nil }
|
||||
authRevokeToken = func(context.Context) error { return errors.New("ignored") }
|
||||
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
|
||||
t.Fatal("profile delete should fail")
|
||||
}
|
||||
authDeleteProfileToken = func(string, string) error { return nil }
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authEnsureProfilesMigration = func(string) error { return errors.New("migrate") }
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||
t.Fatal("migration should fail")
|
||||
}
|
||||
authEnsureProfilesMigration = func(string) error { return nil }
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, errors.New("load") }
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||
t.Fatal("load profiles should fail")
|
||||
}
|
||||
revokes := 0
|
||||
authRevokeToken = func(context.Context) error { revokes++; return nil }
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
authDeleteAllTokenData = func(string) error { return nil }
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 1 {
|
||||
t.Fatalf("empty profiles = %v, revokes=%d", err, revokes)
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "a"}, {CorpID: "b"}}}, nil
|
||||
}
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 3 {
|
||||
t.Fatalf("profile revokes = %v, revokes=%d", err, revokes)
|
||||
}
|
||||
authDeleteAllTokenData = func(string) error { return errors.New("delete all") }
|
||||
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||
t.Fatal("delete-all should fail")
|
||||
}
|
||||
logoutFailure := newAuthLogoutCommand()
|
||||
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
|
||||
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
|
||||
t.Fatal("logout-all command failure should propagate")
|
||||
}
|
||||
logoutFailure = newAuthLogoutCommand()
|
||||
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
|
||||
_ = logoutFailure.Flags().Set("profile", "ding")
|
||||
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
|
||||
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
|
||||
t.Fatal("logout-one command failure should propagate")
|
||||
}
|
||||
|
||||
logout := newAuthLogoutCommand()
|
||||
badLogout := &cobra.Command{}
|
||||
badLogout.Flags().Bool("profile", false, "")
|
||||
badLogout.SetContext(context.Background())
|
||||
if err := logout.RunE(badLogout, nil); err == nil {
|
||||
t.Fatal("invalid logout profile flag should fail")
|
||||
}
|
||||
authDeleteAllTokenData = func(string) error { return nil }
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return &authpkg.ProfilesConfig{}, nil }
|
||||
_, out, _ := authCoverageRoot(logout, "table", false)
|
||||
if err := logout.RunE(logout, nil); err != nil || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("logout = %q, %v", out.String(), err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
authDeleteProfileToken = func(string, string) error { return nil }
|
||||
logout = newAuthLogoutCommand()
|
||||
_, out, _ = authCoverageRoot(logout, "table", false)
|
||||
if err := logout.Flags().Set("profile", "ding"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := logout.RunE(logout, nil); err != nil || strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("embedded logout = %q, %v", out.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldSupported := authPortableExportSupported
|
||||
oldReady := authPortableSourceReady
|
||||
oldTarget := authPortableTargetPopulated
|
||||
oldExport := authExportPortableBundle
|
||||
oldImport := authImportPortableBundle
|
||||
oldAtomic := authAtomicWrite
|
||||
oldRead := authReadFile
|
||||
oldExchange := authOAuthExchange
|
||||
oldDeleteAll := authDeleteAllTokenData
|
||||
oldRemove := authRemove
|
||||
oldDeleteConfig := authDeleteAppConfig
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authPortableExportSupported = oldSupported
|
||||
authPortableSourceReady = oldReady
|
||||
authPortableTargetPopulated = oldTarget
|
||||
authExportPortableBundle = oldExport
|
||||
authImportPortableBundle = oldImport
|
||||
authAtomicWrite = oldAtomic
|
||||
authReadFile = oldRead
|
||||
authOAuthExchange = oldExchange
|
||||
authDeleteAllTokenData = oldDeleteAll
|
||||
authRemove = oldRemove
|
||||
authDeleteAppConfig = oldDeleteConfig
|
||||
})
|
||||
|
||||
export := newAuthExportCommandWithSupport(func() error { return nil })
|
||||
badString := &cobra.Command{}
|
||||
badString.Flags().Bool("output", false, "")
|
||||
if err := export.RunE(badString, nil); err == nil {
|
||||
t.Fatal("invalid output flag should fail")
|
||||
}
|
||||
badBool := &cobra.Command{}
|
||||
badBool.Flags().String("output", "x", "")
|
||||
badBool.Flags().String("base64", "", "")
|
||||
if err := export.RunE(badBool, nil); err == nil {
|
||||
t.Fatal("invalid base64 flag should fail")
|
||||
}
|
||||
_, _, _ = authCoverageRoot(export, "table", false)
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("missing export output should fail")
|
||||
}
|
||||
authPortableExportSupported = func() bool { return false }
|
||||
_ = export.Flags().Set("output", "out")
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("unsupported export should fail")
|
||||
}
|
||||
authPortableExportSupported = func() bool { return true }
|
||||
authPortableSourceReady = func() bool { return false }
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("unready export should fail")
|
||||
}
|
||||
authPortableSourceReady = func() bool { return true }
|
||||
authExportPortableBundle = func(string, io.Writer) error { return errors.New("export") }
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("export failure should propagate")
|
||||
}
|
||||
authExportPortableBundle = func(_ string, w io.Writer) error { _, _ = io.WriteString(w, "bundle"); return nil }
|
||||
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("raw write should fail")
|
||||
}
|
||||
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
|
||||
if err := export.RunE(export, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
export = newAuthExportCommandWithSupport(func() error { return nil })
|
||||
_, out, _ := authCoverageRoot(export, "table", false)
|
||||
_ = export.Flags().Set("base64", "true")
|
||||
export.SetOut(&appFailWriter{err: errors.New("stdout")})
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("stdout failure should propagate")
|
||||
}
|
||||
export.SetOut(out)
|
||||
_ = export.Flags().Set("output", "encoded")
|
||||
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := export.RunE(export, nil); err == nil {
|
||||
t.Fatal("base64 write should fail")
|
||||
}
|
||||
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
|
||||
if err := export.RunE(export, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
badInput := &cobra.Command{}
|
||||
badInput.Flags().Bool("input", false, "")
|
||||
if err := importCmd.RunE(badInput, nil); err == nil {
|
||||
t.Fatal("invalid input flag should fail")
|
||||
}
|
||||
badImportBase64 := &cobra.Command{}
|
||||
badImportBase64.Flags().String("input", "x", "")
|
||||
badImportBase64.Flags().String("base64", "", "")
|
||||
if err := importCmd.RunE(badImportBase64, nil); err == nil {
|
||||
t.Fatal("invalid import base64 flag should fail")
|
||||
}
|
||||
badForce := &cobra.Command{}
|
||||
badForce.Flags().String("input", "x", "")
|
||||
badForce.Flags().Bool("base64", false, "")
|
||||
badForce.Flags().String("force", "", "")
|
||||
if err := importCmd.RunE(badForce, nil); err == nil {
|
||||
t.Fatal("invalid force flag should fail")
|
||||
}
|
||||
_, out, _ = authCoverageRoot(importCmd, "table", false)
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("missing input should fail")
|
||||
}
|
||||
_ = importCmd.Flags().Set("input", "bundle")
|
||||
authPortableTargetPopulated = func(string) bool { return true }
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("populated target should require force")
|
||||
}
|
||||
authPortableTargetPopulated = func(string) bool { return false }
|
||||
authReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("read failure should propagate")
|
||||
}
|
||||
authReadFile = func(string) ([]byte, error) { return []byte("%%%"), nil }
|
||||
_ = importCmd.Flags().Set("base64", "true")
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("invalid base64 should fail")
|
||||
}
|
||||
authReadFile = func(string) ([]byte, error) { return []byte("YnVuZGxl"), nil }
|
||||
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||
return authpkg.PortableImportReport{}, errors.New("import")
|
||||
}
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("import failure should propagate")
|
||||
}
|
||||
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||
return authpkg.PortableImportReport{BundleOS: "other", OSMismatch: true}, nil
|
||||
}
|
||||
if err := importCmd.RunE(importCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||
return authpkg.PortableImportReport{}, nil
|
||||
}
|
||||
if err := importCmd.RunE(importCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exchange := newAuthExchangeCommand()
|
||||
badCode := &cobra.Command{}
|
||||
badCode.Flags().Bool("code", false, "")
|
||||
if err := exchange.RunE(badCode, nil); err == nil {
|
||||
t.Fatal("invalid code flag should fail")
|
||||
}
|
||||
badUID := &cobra.Command{}
|
||||
badUID.Flags().String("code", "code", "")
|
||||
badUID.Flags().Bool("uid", false, "")
|
||||
if err := exchange.RunE(badUID, nil); err == nil {
|
||||
t.Fatal("invalid uid flag should fail")
|
||||
}
|
||||
_, out, _ = authCoverageRoot(exchange, "table", false)
|
||||
if err := exchange.RunE(exchange, nil); err == nil {
|
||||
t.Fatal("missing code should fail")
|
||||
}
|
||||
_ = exchange.Flags().Set("code", "code")
|
||||
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("exchange")
|
||||
}
|
||||
if err := exchange.RunE(exchange, nil); err == nil {
|
||||
t.Fatal("exchange error should propagate")
|
||||
}
|
||||
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{CorpID: "ding", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
_ = exchange.Flags().Set("uid", " user ")
|
||||
if err := exchange.RunE(exchange, nil); err != nil || !strings.Contains(out.String(), "ding") {
|
||||
t.Fatalf("exchange = %q, %v", out.String(), err)
|
||||
}
|
||||
|
||||
reset := newAuthResetCommand()
|
||||
_, out, _ = authCoverageRoot(reset, "table", false)
|
||||
authDeleteAllTokenData = func(string) error { return errors.New("reset") }
|
||||
if err := reset.RunE(reset, nil); err == nil {
|
||||
t.Fatal("reset delete should fail")
|
||||
}
|
||||
removed := 0
|
||||
authDeleteAllTokenData = func(string) error { return nil }
|
||||
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
reset = newAuthResetCommand()
|
||||
_, out, _ = authCoverageRoot(reset, "table", false)
|
||||
if err := reset.RunE(reset, nil); err != nil || strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("embedded reset = %q, %v", out.String(), err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -36,29 +37,33 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
t.Setenv("DWS_CONFIG_DIR", sourceConfig)
|
||||
originalSupported := authPortableExportSupported
|
||||
originalReady := authPortableSourceReady
|
||||
originalExport := authExportPortableBundle
|
||||
originalTarget := authPortableTargetPopulated
|
||||
originalImport := authImportPortableBundle
|
||||
t.Cleanup(func() {
|
||||
authPortableExportSupported = originalSupported
|
||||
authPortableSourceReady = originalReady
|
||||
authExportPortableBundle = originalExport
|
||||
authPortableTargetPopulated = originalTarget
|
||||
authImportPortableBundle = originalImport
|
||||
})
|
||||
|
||||
original := &authpkg.TokenData{
|
||||
AccessToken: "access-cli",
|
||||
RefreshToken: "refresh-cli",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
ClientID: "client-cli",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(sourceConfig, original); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
bundle := []byte("portable-auth-bundle")
|
||||
authPortableExportSupported = func() bool { return true }
|
||||
authPortableSourceReady = func() bool { return true }
|
||||
authExportPortableBundle = func(_ string, w io.Writer) error {
|
||||
_, err := w.Write(bundle)
|
||||
return err
|
||||
}
|
||||
|
||||
exportCmd := NewRootCommand()
|
||||
exportCmd := newAuthExportCommandWithSupport(func() error { return nil })
|
||||
var exported bytes.Buffer
|
||||
exportCmd.SetOut(&exported)
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
|
||||
exportCmd.SetArgs([]string{"--base64"})
|
||||
if err := exportCmd.Execute(); err != nil {
|
||||
t.Fatalf("auth export --base64 error = %v", err)
|
||||
}
|
||||
@@ -72,34 +77,245 @@ func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
t.Fatalf("write input bundle error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(targetRoot, "target-keychain")
|
||||
targetConfig := filepath.Join(targetRoot, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
t.Setenv("DWS_CONFIG_DIR", targetConfig)
|
||||
authPortableTargetPopulated = func(string) bool { return false }
|
||||
var imported []byte
|
||||
authImportPortableBundle = func(_ string, r io.Reader) (authpkg.PortableImportReport, error) {
|
||||
var err error
|
||||
imported, err = io.ReadAll(r)
|
||||
return authpkg.PortableImportReport{}, err
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", inputPath, "--base64"})
|
||||
if err := importCmd.Execute(); err != nil {
|
||||
t.Fatalf("auth import --base64 error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(imported, bundle) {
|
||||
t.Fatalf("imported bundle = %q, want %q", imported, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(t *testing.T) {
|
||||
exportCmd := newAuthExportCommandWithSupport(func() error {
|
||||
return errors.New("portable auth export is unavailable for the test backend")
|
||||
})
|
||||
exportCmd.SetOut(&bytes.Buffer{})
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"--base64"})
|
||||
|
||||
err := exportCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth export should reject an unsupported credential backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "test backend") {
|
||||
t.Fatalf("error = %v, want backend-specific reason", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
t.Cleanup(CloseFileLogger)
|
||||
|
||||
exportCmd := NewRootCommand()
|
||||
exportCmd.SetOut(&bytes.Buffer{})
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
|
||||
|
||||
err := exportCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth export should reject the Windows DPAPI backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
keychainDir := filepath.Join(root, "keychain")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error {
|
||||
return errors.New("portable auth import is unavailable for the test backend")
|
||||
})
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", filepath.Join(root, "missing-bundle.tar.gz")})
|
||||
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject an unsupported credential backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "test backend") {
|
||||
t.Fatalf("error = %v, want backend-specific reason", err)
|
||||
}
|
||||
for _, path := range []string{configDir, keychainDir} {
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("unsupported import touched %s: stat error = %v", path, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
// NewRootCommand initializes the normal CLI file logger below configDir.
|
||||
// Register its cleanup after TempDir so the Windows handle is closed before
|
||||
// testing removes the temporary directory.
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
keychainDir := filepath.Join(root, "keychain")
|
||||
inputPath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(inputPath, []byte("the capability guard must run before this input is read"), 0o600); err != nil {
|
||||
t.Fatalf("write input sentinel error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath, "--base64"})
|
||||
if err := importCmd.Execute(); err != nil {
|
||||
t.Fatalf("auth import --base64 error = %v", err)
|
||||
}
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath})
|
||||
|
||||
loaded, err := authpkg.LoadTokenData(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after CLI import error = %v", err)
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject the Windows DPAPI backend")
|
||||
}
|
||||
if loaded.RefreshToken != original.RefreshToken {
|
||||
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !loaded.IsRefreshTokenValid() {
|
||||
t.Fatal("refresh token should remain valid after CLI import")
|
||||
for _, want := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, want)
|
||||
}
|
||||
}
|
||||
// The root command may create configDir/logs as part of normal CLI startup.
|
||||
// The capability guard must still run before any auth state is imported.
|
||||
for _, path := range []string{
|
||||
keychainDir,
|
||||
authpkg.ProfilesPath(configDir),
|
||||
filepath.Join(configDir, "app.json"),
|
||||
filepath.Join(configDir, "token.json"),
|
||||
} {
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackendWithPopulatedCredentialBeforeRead(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
||||
}
|
||||
|
||||
previous, previousErr := authpkg.LoadTokenDataKeychain()
|
||||
if previousErr != nil && !errors.Is(previousErr, authpkg.ErrTokenDataNotFound) {
|
||||
t.Fatalf("capture existing Windows credential: %v", previousErr)
|
||||
}
|
||||
hadPrevious := previousErr == nil
|
||||
t.Cleanup(func() {
|
||||
if hadPrevious {
|
||||
_ = authpkg.SaveTokenDataKeychain(previous)
|
||||
} else {
|
||||
_ = authpkg.DeleteTokenDataKeychain()
|
||||
}
|
||||
})
|
||||
|
||||
want := &authpkg.TokenData{
|
||||
AccessToken: "windows-existing-access",
|
||||
RefreshToken: "windows-existing-refresh",
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "windows-existing-corp",
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychain(want); err != nil {
|
||||
t.Fatalf("seed cleanup-scoped Windows DPAPI credential: %v", err)
|
||||
}
|
||||
|
||||
originalTarget := authPortableTargetPopulated
|
||||
originalRead := authReadFile
|
||||
targetChecks := 0
|
||||
bundleReads := 0
|
||||
authPortableTargetPopulated = func(configDir string) bool {
|
||||
targetChecks++
|
||||
return originalTarget(configDir)
|
||||
}
|
||||
authReadFile = func(path string) ([]byte, error) {
|
||||
bundleReads++
|
||||
return originalRead(path)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
authPortableTargetPopulated = originalTarget
|
||||
authReadFile = originalRead
|
||||
})
|
||||
|
||||
root := t.TempDir()
|
||||
inputPath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(inputPath, []byte("unsupported Windows import must not read this bundle"), 0o600); err != nil {
|
||||
t.Fatalf("write bundle sentinel: %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(root, ".dws"))
|
||||
|
||||
importCmd := newAuthImportCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", inputPath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import should reject a populated Windows DPAPI backend")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
for _, required := range []string{"Windows", "DPAPI", "HKCU"} {
|
||||
if !strings.Contains(err.Error(), required) {
|
||||
t.Fatalf("error = %v, want substring %q", err, required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("unsupported Windows import suggested impossible --force remediation: %v", err)
|
||||
}
|
||||
if targetChecks != 0 || bundleReads != 0 {
|
||||
t.Fatalf("unsupported Windows import inspected credentials/bundle: target_checks=%d bundle_reads=%d", targetChecks, bundleReads)
|
||||
}
|
||||
|
||||
got, err := authpkg.LoadTokenDataKeychain()
|
||||
if err != nil {
|
||||
t.Fatalf("reload Windows DPAPI credential after rejection: %v", err)
|
||||
}
|
||||
if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || got.CorpID != want.CorpID {
|
||||
t.Fatalf("Windows auth state changed after rejected import: got=%#v want=%#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
@@ -117,11 +333,42 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
t.Fatalf("write bundle stub error = %v", err)
|
||||
}
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
var stderr bytes.Buffer
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&stderr)
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", bundlePath})
|
||||
importCmd.SetArgs([]string{"--input", bundlePath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import without --force should fail when auth exists")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("error = %v, want --force hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
originalTarget := authPortableTargetPopulated
|
||||
authPortableTargetPopulated = func(string) bool { return true }
|
||||
t.Cleanup(func() { authPortableTargetPopulated = originalTarget })
|
||||
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
bundlePath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
|
||||
t.Fatalf("write bundle stub error = %v", err)
|
||||
}
|
||||
|
||||
importCmd := newAuthImportCommandWithSupport(func() error { return nil })
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"--input", bundlePath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import without --force should fail when auth exists")
|
||||
@@ -1073,9 +1320,11 @@ func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
t.Cleanup(func() {
|
||||
_ = authpkg.DeleteAllTokenData(configDir)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
CloseFileLogger()
|
||||
})
|
||||
|
||||
for _, token := range tokens {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAuthMigrateKeychainRemainingBranches(t *testing.T) {
|
||||
originalMigrate, originalTarget := migrateKeychainToFileDEK, authMigrateTarget
|
||||
t.Cleanup(func() {
|
||||
migrateKeychainToFileDEK, authMigrateTarget = originalMigrate, originalTarget
|
||||
})
|
||||
newRoot := func(format string) (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", format, "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
return root, &output
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
|
||||
authMigrateTarget = func(*cobra.Command) (string, error) { return "", errors.New("flag") }
|
||||
root, _ := newRoot("text")
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--to") {
|
||||
t.Fatalf("target flag error = %v", err)
|
||||
}
|
||||
authMigrateTarget = originalTarget
|
||||
root, _ = newRoot("text")
|
||||
root.SetArgs([]string{"migrate-keychain", "--to", "other", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "file-dek") {
|
||||
t.Fatalf("unsupported target error = %v", err)
|
||||
}
|
||||
|
||||
migrateKeychainToFileDEK = func(string, bool) (int, error) { return 0, errors.New("backend") }
|
||||
root, _ = newRoot("text")
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "backend") {
|
||||
t.Fatalf("migration backend error = %v", err)
|
||||
}
|
||||
migrateKeychainToFileDEK = func(string, bool) (int, error) { return 3, nil }
|
||||
for _, test := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"migrate-keychain", "--dry-run"}, "预检通过"},
|
||||
{[]string{"migrate-keychain", "--yes"}, "迁移完成"},
|
||||
} {
|
||||
root, output := newRoot("text")
|
||||
root.SetArgs(test.args)
|
||||
if err := root.Execute(); err != nil || !strings.Contains(output.String(), test.want) {
|
||||
t.Fatalf("migrate %v = %v, %q", test.args, err, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the original test name for the focused macOS auth workflow while also
|
||||
// opting the coverage fixture into the native platform coverage gate.
|
||||
func TestCrossPlatformCoverageAuthMigrateKeychainRemainingBranches(t *testing.T) {
|
||||
TestAuthMigrateKeychainRemainingBranches(t)
|
||||
}
|
||||
@@ -65,10 +65,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
case "", "json":
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, err := json.MarshalIndent(notice, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
|
||||
@@ -33,8 +33,11 @@ func init() {
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
var (
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
userHomeDir = os.UserHomeDir
|
||||
executablePath = os.Executable
|
||||
evaluateSymlink = filepath.EvalSymlinks
|
||||
)
|
||||
|
||||
func defaultConfigDir() string {
|
||||
@@ -44,7 +47,7 @@ func defaultConfigDir() string {
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
homeDir, err := userHomeDir()
|
||||
if err != nil {
|
||||
return exeRelativeConfigDir()
|
||||
}
|
||||
@@ -52,11 +55,11 @@ func defaultConfigDir() string {
|
||||
}
|
||||
|
||||
func exeRelativeConfigDir() string {
|
||||
exePath, err := os.Executable()
|
||||
exePath, err := executablePath()
|
||||
if err != nil {
|
||||
return ".dws"
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(exePath)
|
||||
realPath, err := evaluateSymlink(exePath)
|
||||
if err != nil {
|
||||
realPath = exePath
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
|
||||
func TestDefaultConfigDirUsesHomeDirectoryInOSSMode(t *testing.T) {
|
||||
homeDir := filepath.Join(t.TempDir(), "home")
|
||||
t.Setenv("HOME", homeDir)
|
||||
originalUserHomeDir := userHomeDir
|
||||
userHomeDir = func() (string, error) { return homeDir, nil }
|
||||
t.Cleanup(func() { userHomeDir = originalUserHomeDir })
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
|
||||
got := defaultConfigDir()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,307 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
if !initOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32601, "message": "init"}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"protocolVersion": "2025-03-26"}})
|
||||
case "tools/list":
|
||||
if !listOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -1, "message": "list"}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]any{
|
||||
"tools": []any{map[string]any{
|
||||
"name": "tool",
|
||||
"description": "desc",
|
||||
"inputSchema": map[string]any{
|
||||
"properties": map[string]any{"id": map[string]any{"type": "string"}},
|
||||
"required": []any{"id", 1, ""},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{}})
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
|
||||
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
|
||||
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
|
||||
oldProvider := newAccessTokenProvider
|
||||
oldManager := newLegacyTokenManager
|
||||
t.Cleanup(func() {
|
||||
newAccessTokenProvider = oldProvider
|
||||
newLegacyTokenManager = oldManager
|
||||
})
|
||||
cmd := &cobra.Command{Use: "api"}
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetContext(context.Background())
|
||||
|
||||
invalid := []struct {
|
||||
args []string
|
||||
gf GlobalFlags
|
||||
af apiFlags
|
||||
}{
|
||||
{[]string{"GET", "/x?a=1"}, GlobalFlags{}, apiFlags{}},
|
||||
{[]string{"TRACE", "/x"}, GlobalFlags{}, apiFlags{}},
|
||||
{[]string{"GET", "../x"}, GlobalFlags{}, apiFlags{}},
|
||||
{[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "x\x00"}},
|
||||
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "x\x00"}},
|
||||
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{params: "-", data: "-"}},
|
||||
{[]string{"GET", "/x"}, GlobalFlags{Output: "x"}, apiFlags{pageAll: true}},
|
||||
{[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "{"}},
|
||||
{[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "{"}},
|
||||
{[]string{"GET", "https://evil.test/x"}, GlobalFlags{Token: "t"}, apiFlags{}},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
if err := runAPI(cmd, tc.args, &tc.gf, &tc.af); err == nil {
|
||||
t.Fatalf("invalid API %#v succeeded", tc)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "a=1&empty=&=x", "a=1&b=2"} {
|
||||
if got := parseQueryStringToJSON(raw); got == "" {
|
||||
t.Fatalf("query JSON %q empty", raw)
|
||||
}
|
||||
}
|
||||
if got, err := resolveRawAPIToken(context.Background(), " token "); err != nil || got != "token" {
|
||||
t.Fatalf("explicit raw token = %q, %v", got, err)
|
||||
}
|
||||
authpkg.SetClientID("")
|
||||
authpkg.SetClientSecret("")
|
||||
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
|
||||
t.Fatal("missing app credentials succeeded")
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "fail") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"error":"bad"}`)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{1}, "hasMore": false})
|
||||
}))
|
||||
defer server.Close()
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
host = strings.Split(host, ":")[0]
|
||||
apiclient.AllowedHosts[host] = true
|
||||
t.Cleanup(func() { delete(apiclient.AllowedHosts, host) })
|
||||
gf := &GlobalFlags{Token: "token", DryRun: true, Format: "json", Timeout: 1}
|
||||
af := &apiFlags{baseURL: server.URL}
|
||||
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
|
||||
t.Fatalf("API dry run = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
gf.DryRun = false
|
||||
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
|
||||
t.Fatalf("API request = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
af.pageAll = true
|
||||
if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
|
||||
t.Fatalf("API pagination = %q, %v", out.String(), err)
|
||||
}
|
||||
client := apiclient.NewClient("token", server.URL)
|
||||
if err := runPaginated(context.Background(), client, apiclient.RawAPIRequest{Method: "GET", Path: "/fail"}, &apiFlags{}, apiclient.ResponseOptions{Out: io.Discard, ErrOut: io.Discard}); err == nil {
|
||||
t.Fatal("failed pagination succeeded")
|
||||
}
|
||||
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), "", ""); err == nil {
|
||||
t.Fatal("empty auxiliary config succeeded")
|
||||
}
|
||||
if got, err := ResolveAuxiliaryAccessToken(context.Background(), "ignored", " explicit "); err != nil || got != "explicit" {
|
||||
t.Fatalf("explicit auxiliary token = %q, %v", got, err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{token: "saved"} }
|
||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{} }
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), dir); err != nil || got != "saved" {
|
||||
t.Fatalf("saved access token = %q, %v", got, err)
|
||||
}
|
||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
|
||||
missing := t.TempDir()
|
||||
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
|
||||
t.Fatalf("missing access token = %q, %v", got, err)
|
||||
}
|
||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
|
||||
t.Fatal("missing auxiliary credentials succeeded")
|
||||
}
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), ""); err == nil {
|
||||
t.Fatal("empty force refresh config succeeded")
|
||||
}
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), missing); err == nil {
|
||||
t.Fatal("missing force refresh token succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = resolveVerbosity(nil)
|
||||
for _, flags := range []struct {
|
||||
debug bool
|
||||
verbose bool
|
||||
format string
|
||||
json bool
|
||||
}{{}, {verbose: true}, {debug: true}, {format: "json"}, {format: "table"}, {json: true}} {
|
||||
flagCmd := &cobra.Command{Use: "flags"}
|
||||
flagCmd.Flags().Bool("debug", flags.debug, "")
|
||||
flagCmd.Flags().Bool("verbose", flags.verbose, "")
|
||||
flagCmd.Flags().String("format", flags.format, "")
|
||||
flagCmd.Flags().Bool("json", false, "")
|
||||
if flags.json {
|
||||
_ = flagCmd.Flags().Set("json", "true")
|
||||
}
|
||||
_ = resolveVerbosity(flagCmd)
|
||||
_ = commandRequestsJSONErrors(flagCmd)
|
||||
_ = wantsJSONErrors(flagCmd)
|
||||
}
|
||||
_ = commandRequestsJSONErrors(nil)
|
||||
_ = wantsJSONErrors(nil)
|
||||
if got, changed := normalizeProfileFlagArgs([]string{"--profile", "a,", "b"}); !changed || len(got) == 0 {
|
||||
t.Fatalf("profile args = %#v, %v", got, changed)
|
||||
}
|
||||
if _, changed := normalizeProfileFlagArgs([]string{"--profile"}); changed {
|
||||
t.Fatal("incomplete profile flag changed")
|
||||
}
|
||||
if preparseProfileFlag([]string{"--profile=a"}) != "a" || preparseProfileFlag([]string{"--profile", "b"}) != "b" || preparseProfileFlag(nil) != "" {
|
||||
t.Fatal("profile preparse mismatch")
|
||||
}
|
||||
if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) {
|
||||
t.Fatal("argsChanged mismatch")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "root"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().String("output", "", "")
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "nested", "out.txt")
|
||||
_ = cmd.Flags().Set("output", path)
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(cmd.OutOrStdout(), "data")
|
||||
if err := closeOutputSink(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := &cobra.Command{Use: "local"}
|
||||
local.SetContext(context.Background())
|
||||
local.SetOut(io.Discard)
|
||||
local.Flags().String("output", "", "")
|
||||
if err := configureOutputSink(local); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateOptionalPath("--x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateOptionalPath("--x", "bad\x00path"); err == nil {
|
||||
t.Fatal("unsafe path succeeded")
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "root", Short: "root"}
|
||||
bindPersistentFlags(root, &GlobalFlags{})
|
||||
root.AddCommand(&cobra.Command{Use: "alpha", Short: "alpha"}, &cobra.Command{Use: "hidden", Hidden: true})
|
||||
configureRootHelp(root)
|
||||
var help bytes.Buffer
|
||||
root.SetOut(&help)
|
||||
_ = root.Help()
|
||||
renderRootGlobalFlags(root)
|
||||
_ = visiblePersistentFlags(root)
|
||||
for _, command := range root.Commands() {
|
||||
_ = commandShort(command)
|
||||
}
|
||||
_ = visibleMCPRootCommands(root)
|
||||
_ = visibleUtilityRootCommands(root)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("a", time.Microsecond)
|
||||
tc.Record("b", 2*time.Second)
|
||||
for _, d := range []time.Duration{time.Nanosecond, time.Microsecond, time.Millisecond, time.Second} {
|
||||
_ = formatDuration(d)
|
||||
}
|
||||
for _, debug := range []bool{false, true} {
|
||||
if debug {
|
||||
t.Setenv(PerfDebugEnv, "1")
|
||||
} else {
|
||||
t.Setenv(PerfDebugEnv, "")
|
||||
}
|
||||
tc.PrintIfEnabled()
|
||||
}
|
||||
var timingOut bytes.Buffer
|
||||
tc.Print(&timingOut)
|
||||
if timingOut.Len() == 0 {
|
||||
t.Fatal("timing output empty")
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if defaultPerfReportPath() == "" {
|
||||
t.Fatal("default perf path empty")
|
||||
}
|
||||
t.Setenv(PerfReportEnv, "auto")
|
||||
tc.WriteReportIfEnabled("v", "cmd")
|
||||
if _, err := LoadLatestReport(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(PerfReportEnv, "")
|
||||
tc.WriteReportIfEnabled("v", "cmd")
|
||||
_ = exeRelativeConfigDir()
|
||||
|
||||
merged := mergeTopLevelCommands([]*cobra.Command{{Use: "a"}, {Use: "a"}, {Use: "b"}, nil})
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged commands = %#v", merged)
|
||||
}
|
||||
dedupRoot := &cobra.Command{Use: "root"}
|
||||
dedupRoot.AddCommand(&cobra.Command{Use: "same"}, &cobra.Command{Use: "same"})
|
||||
deduplicateCommands(dedupRoot)
|
||||
addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
|
||||
_ = newCompletionCommand(dedupRoot)
|
||||
_ = newCatalogCommand(nil)
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
@@ -309,9 +309,6 @@ func editionServerEndpoint(productID string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
hooks := edition.Get()
|
||||
if hooks == nil {
|
||||
return "", false
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var doctorKeychainDiagnose = keychain.Diagnose
|
||||
var (
|
||||
doctorKeychainDiagnose = keychain.Diagnose
|
||||
doctorAuthStatus = (*authpkg.OAuthProvider).Status
|
||||
doctorAuthAccessToken = (*authpkg.OAuthProvider).GetAccessToken
|
||||
doctorHTTPDo = (*http.Client).Do
|
||||
doctorFetchLatestRelease = func() (*upgrade.ReleaseInfo, error) { return upgrade.NewClient().FetchLatestRelease() }
|
||||
doctorNeedsUpgrade = upgrade.NeedsUpgrade
|
||||
)
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
@@ -136,7 +143,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
|
||||
data, err := provider.Status()
|
||||
data, err := doctorAuthStatus(provider)
|
||||
if err != nil || data == nil {
|
||||
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
||||
r := checkResult{
|
||||
@@ -164,7 +171,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
||||
if !data.IsAccessTokenValid() {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||||
_, refreshErr := doctorAuthAccessToken(provider, refreshCtx)
|
||||
cancel()
|
||||
if refreshErr != nil {
|
||||
r := checkResult{
|
||||
@@ -263,7 +270,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := doctorHTTPDo(httpClient, req)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
@@ -317,8 +324,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
||||
|
||||
currentVer := version
|
||||
|
||||
client := upgrade.NewClient()
|
||||
latest, err := client.FetchLatestRelease()
|
||||
latest, err := doctorFetchLatestRelease()
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
@@ -332,7 +338,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
||||
return r
|
||||
}
|
||||
|
||||
if upgrade.NeedsUpgrade(currentVer, latest.Version) {
|
||||
if doctorNeedsUpgrade(currentVer, latest.Version) {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusWarn,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDoctorRemainingCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldDiagnose := doctorKeychainDiagnose
|
||||
oldStatus := doctorAuthStatus
|
||||
oldAccess := doctorAuthAccessToken
|
||||
oldHTTP := doctorHTTPDo
|
||||
oldLatest := doctorFetchLatestRelease
|
||||
oldNeeds := doctorNeedsUpgrade
|
||||
oldRead := timingReadFile
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
doctorKeychainDiagnose = oldDiagnose
|
||||
doctorAuthStatus = oldStatus
|
||||
doctorAuthAccessToken = oldAccess
|
||||
doctorHTTPDo = oldHTTP
|
||||
doctorFetchLatestRelease = oldLatest
|
||||
doctorNeedsUpgrade = oldNeeds
|
||||
timingReadFile = oldRead
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
doctorKeychainDiagnose = func() keychain.Diagnostic { return keychain.Diagnostic{OK: true, Message: "ok"} }
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
|
||||
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
|
||||
t.Fatalf("missing auth = %#v", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
if got := doctorCheckAuth(context.Background(), io.Discard, true); got.Status != statusFail || got.Hint != "" {
|
||||
t.Fatalf("embedded missing auth = %#v", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
now := time.Now()
|
||||
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour)}
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
|
||||
t.Fatalf("valid auth = %#v", got)
|
||||
}
|
||||
refresh := &authpkg.TokenData{RefreshToken: "r", RefreshExpAt: now.Add(time.Hour)}
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return refresh, nil }
|
||||
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
|
||||
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusWarn {
|
||||
t.Fatalf("refresh failure = %#v", got)
|
||||
}
|
||||
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "a", nil }
|
||||
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
|
||||
t.Fatalf("refresh success = %#v", got)
|
||||
}
|
||||
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour)}
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
|
||||
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
|
||||
t.Fatalf("expired auth = %#v", got)
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(":"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
|
||||
t.Fatalf("invalid network URL = %#v", got)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://example.test"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, errors.New("network") }
|
||||
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
|
||||
t.Fatalf("network failure = %#v", got)
|
||||
}
|
||||
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
}
|
||||
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusPass {
|
||||
t.Fatalf("network success = %#v", got)
|
||||
}
|
||||
|
||||
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return nil, errors.New("latest") }
|
||||
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusFail {
|
||||
t.Fatalf("version failure = %#v", got)
|
||||
}
|
||||
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: "99.0.0"}, nil }
|
||||
doctorNeedsUpgrade = func(string, string) bool { return true }
|
||||
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusWarn {
|
||||
t.Fatalf("version warning = %#v", got)
|
||||
}
|
||||
doctorNeedsUpgrade = func(string, string) bool { return false }
|
||||
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusPass {
|
||||
t.Fatalf("version pass = %#v", got)
|
||||
}
|
||||
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: version}, nil }
|
||||
timingReadFile = func(string) ([]byte, error) {
|
||||
return []byte(`{"command":"test","timestamp":"2026-01-01T00:00:00Z","phases":[]}`), nil
|
||||
}
|
||||
doctor := newDoctorCommand()
|
||||
doctor.SetContext(context.Background())
|
||||
doctor.SetOut(io.Discard)
|
||||
_ = doctor.Flags().Set("json", "true")
|
||||
_ = doctor.Flags().Set("perf", "true")
|
||||
_ = doctor.Flags().Set("timeout", "0")
|
||||
if err := doctor.RunE(doctor, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, errors.New("not logged in") }
|
||||
doctor = newDoctorCommand()
|
||||
doctor.SetContext(context.Background())
|
||||
doctor.SetOut(io.Discard)
|
||||
if err := doctor.RunE(doctor, nil); err == nil {
|
||||
t.Fatal("doctor failures should return an error")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,31 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
eventRunPersonalConsume = runPersonalEventConsume
|
||||
eventRunPersonalList = runPersonalEventList
|
||||
eventRunPersonalStatus = runPersonalEventStatus
|
||||
eventRunPersonalStop = runPersonalEventStop
|
||||
eventNormalizeAs = normalizeEventAs
|
||||
eventResolveCredentials = resolveEventCredentials
|
||||
eventConsumeRun = consume.Run
|
||||
eventRunForeground = runForegroundBus
|
||||
eventNewEventSource = newEventSource
|
||||
eventNewDingtalkSource = source.New
|
||||
eventResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||
eventBusRun = bus.Run
|
||||
eventReadyFDFromEnv = busctl.ReadyFDFromEnv
|
||||
eventResolvePersonal = resolvePersonalEventIdentity
|
||||
eventNewPersonalSource = newPersonalStreamSource
|
||||
eventMkdirAll = os.MkdirAll
|
||||
eventOpenFile = os.OpenFile
|
||||
eventEnumerateBuses = busctl.EnumerateBuses
|
||||
eventFindBus = busctl.FindBusByClientID
|
||||
eventQueryEntry = busctl.QueryEntry
|
||||
eventStopBus = busctl.Stop
|
||||
eventResolveAppCredentials = authpkg.ResolveAppCredentialsStrict
|
||||
)
|
||||
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
@@ -112,7 +137,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,7 +160,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
personalOpts.StreamTicketMode = streamOpts.Mode
|
||||
personalOpts.StreamTicketURL = streamOpts.TicketURL
|
||||
personalOpts.StreamSourceID = streamOpts.SourceID
|
||||
return runPersonalEventConsume(c, personalOpts)
|
||||
return eventRunPersonalConsume(c, personalOpts)
|
||||
}
|
||||
if personalOpts.DebugRawEvents {
|
||||
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
|
||||
@@ -164,7 +189,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
// Portal ticket normal mode uses portal-managed app credentials, so
|
||||
// local ClientSecret is intentionally not required there.
|
||||
configDir := defaultConfigDir()
|
||||
clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts)
|
||||
clientID, clientSecret, err := eventResolveCredentials(configDir, streamOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume: %w", err)
|
||||
}
|
||||
@@ -221,9 +246,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style,
|
||||
// unbounded run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(maxEvents, duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyEventConsumeStdin(&cfg, maxEvents, duration, c.InOrStdin())
|
||||
|
||||
// Step 5: validation (flag-only rules).
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
@@ -239,9 +262,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
// Step 6: foreground mode runs the bus in-process. Otherwise
|
||||
// consume.Run discovers / forks the bus and dials it.
|
||||
if foreground {
|
||||
return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts)
|
||||
return eventRunForeground(ctx, cfg, configDir, clientSecret, streamOpts)
|
||||
}
|
||||
return consume.Run(ctx, cfg)
|
||||
return eventConsumeRun(ctx, cfg)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -324,7 +347,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
// can run `dws event consume` from another shell to consume the events.
|
||||
// v2 may add a "foreground + in-process consumer" combined mode.
|
||||
func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error {
|
||||
src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
|
||||
src, err := eventNewEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -339,7 +362,7 @@ func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, client
|
||||
Logger: slog.Default(),
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return bus.Run(ctx, busCfg)
|
||||
return eventBusRun(ctx, busCfg)
|
||||
}
|
||||
|
||||
type eventStreamTicketOptions struct {
|
||||
@@ -389,13 +412,13 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
|
||||
|
||||
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
if !streamOpts.enabled() {
|
||||
return source.New(source.Config{
|
||||
return eventNewDingtalkSource(source.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
})
|
||||
}
|
||||
|
||||
token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
token, err := eventResolveAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
|
||||
}
|
||||
@@ -410,7 +433,7 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
||||
portalClientSecret = ""
|
||||
}
|
||||
|
||||
return source.New(source.Config{
|
||||
return eventNewDingtalkSource(source.Config{
|
||||
ClientID: portalClientID,
|
||||
ClientSecret: portalClientSecret,
|
||||
PortalTicket: &source.PortalTicketConfig{
|
||||
@@ -474,7 +497,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
|
||||
// Acquire ReadyPipe early so pre-bus.Run failures can signal
|
||||
// 'E' to the parent process instead of silently dying.
|
||||
readyPipe := busctl.ReadyFDFromEnv()
|
||||
readyPipe := eventReadyFDFromEnv()
|
||||
failEarly := func(err error) error {
|
||||
if readyPipe != nil {
|
||||
// 'E' signals failure; the trailing text lets the parent
|
||||
@@ -495,7 +518,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
sourceKind = dwsevent.SourceKindAppStream
|
||||
}
|
||||
if sourceKind == dwsevent.SourceKindPersonalStream {
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID)
|
||||
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
}
|
||||
@@ -506,7 +529,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
||||
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: streamOpts.Mode,
|
||||
@@ -516,8 +539,8 @@ func newEventBusCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return failEarly(err)
|
||||
}
|
||||
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
|
||||
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
|
||||
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
|
||||
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
||||
defer lf.Close()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
@@ -537,10 +560,10 @@ func newEventBusCommand() *cobra.Command {
|
||||
Logger: slog.Default(),
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return bus.Run(ctx, busCfg)
|
||||
return eventBusRun(ctx, busCfg)
|
||||
}
|
||||
|
||||
resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts)
|
||||
resolvedID, secret, err := eventResolveCredentials(configDir, streamOpts)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
}
|
||||
@@ -553,7 +576,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
|
||||
src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts)
|
||||
src, err := eventNewEventSource(ctx, configDir, clientID, secret, streamOpts)
|
||||
if err != nil {
|
||||
return failEarly(err)
|
||||
}
|
||||
@@ -562,8 +585,8 @@ func newEventBusCommand() *cobra.Command {
|
||||
// own log lines never pollute stdout/stderr (which busctl/Spawn
|
||||
// detached). Best-effort: if mkdir / open fails we fall back
|
||||
// to slog.Default (stderr) so we at least see startup errors.
|
||||
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
|
||||
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
|
||||
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
|
||||
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
||||
defer lf.Close()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
@@ -585,7 +608,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
// env-var tuning (only fills in fields left at zero; explicit
|
||||
// flags above keep precedence).
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return bus.Run(ctx, busCfg)
|
||||
return eventBusRun(ctx, busCfg)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&clientIDOverride, "client-id", "",
|
||||
@@ -642,7 +665,7 @@ func newEventListCommand() *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -650,7 +673,7 @@ func newEventListCommand() *cobra.Command {
|
||||
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil {
|
||||
return fmt.Errorf("event list: %w", err)
|
||||
}
|
||||
return runPersonalEventList(c, personalListOptions{
|
||||
return eventRunPersonalList(c, personalListOptions{
|
||||
Category: category,
|
||||
EnabledOnly: enabledOnly,
|
||||
IncludePending: includePending,
|
||||
@@ -704,7 +727,7 @@ func newEventStatusCommand() *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -713,7 +736,7 @@ func newEventStatusCommand() *cobra.Command {
|
||||
return fmt.Errorf("event status: %w", err)
|
||||
}
|
||||
personalOpts.Format = formatRaw
|
||||
return runPersonalEventStatus(c, personalOpts)
|
||||
return eventRunPersonalStatus(c, personalOpts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event status: %w", err)
|
||||
@@ -764,14 +787,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
||||
|
||||
// --all-editions trumps --all (scan whole tree)
|
||||
if allEditions {
|
||||
entries, err := busctl.EnumerateBuses(configDir, "")
|
||||
entries, err := eventEnumerateBuses(configDir, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queryAll(entries), nil
|
||||
}
|
||||
if all {
|
||||
entries, err := busctl.EnumerateBuses(configDir, editionName)
|
||||
entries, err := eventEnumerateBuses(configDir, editionName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,14 +805,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
||||
// otherwise resolve via strict resolver.
|
||||
clientID := clientIDOver
|
||||
if clientID == "" {
|
||||
resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
|
||||
resolved, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err)
|
||||
}
|
||||
clientID = resolved
|
||||
}
|
||||
hash := dwsevent.ClientIDHash(clientID)
|
||||
entry := busctl.FindBusByClientID(configDir, editionName, hash)
|
||||
entry := eventFindBus(configDir, editionName, hash)
|
||||
if entry == nil {
|
||||
// No directory at all — render an empty "not running" so the user
|
||||
// sees a useful answer instead of an error.
|
||||
@@ -813,13 +836,13 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
||||
if entry.Meta == nil {
|
||||
entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName}
|
||||
}
|
||||
return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil
|
||||
return []busctl.EntryStatus{eventQueryEntry(*entry)}, nil
|
||||
}
|
||||
|
||||
func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus {
|
||||
out := make([]busctl.EntryStatus, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, busctl.QueryEntry(e))
|
||||
out = append(out, eventQueryEntry(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -989,7 +1012,7 @@ func newEventStopCommand() *cobra.Command {
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1008,7 +1031,7 @@ func newEventStopCommand() *cobra.Command {
|
||||
if !eventStopConfirmed(c) {
|
||||
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
|
||||
}
|
||||
return runPersonalEventStop(c, opts)
|
||||
return eventRunPersonalStop(c, opts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
@@ -1023,14 +1046,14 @@ func newEventStopCommand() *cobra.Command {
|
||||
return eventStopConfirmationRequired("event stop 会停止事件消费")
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
|
||||
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
}
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
@@ -1199,6 +1222,12 @@ func shouldWatchStdinEOF(maxEvents int, duration time.Duration) bool {
|
||||
return fi.Mode()&os.ModeCharDevice == 0
|
||||
}
|
||||
|
||||
func applyEventConsumeStdin(cfg *consume.Config, maxEvents int, duration time.Duration, stdin io.Reader) {
|
||||
if cfg != nil && shouldWatchStdinEOF(maxEvents, duration) {
|
||||
cfg.Stdin = stdin
|
||||
}
|
||||
}
|
||||
|
||||
// eventTypesWithDefault picks the catch-all list from registry when the
|
||||
// user did not pass --event-types.
|
||||
func eventTypesWithDefault(types []string) []string {
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventConsumeCommandAllBranchesCoverage(t *testing.T) {
|
||||
oldPersonal := eventRunPersonalConsume
|
||||
oldCreds, oldConsume, oldForeground := eventResolveCredentials, eventConsumeRun, eventRunForeground
|
||||
oldNormalize := eventNormalizeAs
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalConsume = oldPersonal
|
||||
eventResolveCredentials, eventConsumeRun, eventRunForeground = oldCreds, oldConsume, oldForeground
|
||||
eventNormalizeAs = oldNormalize
|
||||
})
|
||||
eventNormalizeAs = func(value string) (string, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "app") {
|
||||
return "app", nil
|
||||
}
|
||||
return normalizeEventAs(value)
|
||||
}
|
||||
fail := errors.New("failure")
|
||||
personalCalled := false
|
||||
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error { personalCalled = true; return nil }
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := cmd.RunE(cmd, []string{"event.key"}); err != nil || !personalCalled {
|
||||
t.Fatalf("personal consume = %v, called=%v", err, personalCalled)
|
||||
}
|
||||
|
||||
makeApp := func() *cobra.Command {
|
||||
command := newEventConsumeCommand()
|
||||
command.SetContext(context.Background())
|
||||
command.SetOut(io.Discard)
|
||||
command.SetErr(io.Discard)
|
||||
_ = command.Flags().Set("as", "app")
|
||||
return command
|
||||
}
|
||||
invalid := newEventConsumeCommand()
|
||||
_ = invalid.Flags().Set("as", "invalid")
|
||||
if err := invalid.RunE(invalid, nil); err == nil {
|
||||
t.Fatal("invalid event identity succeeded")
|
||||
}
|
||||
debug := makeApp()
|
||||
_ = debug.Flags().Set("debug-raw-events", "true")
|
||||
if err := debug.RunE(debug, nil); err == nil {
|
||||
t.Fatal("app debug-raw-events succeeded")
|
||||
}
|
||||
userFlag := makeApp()
|
||||
_ = userFlag.Flags().Set("subscribe-id", "sub")
|
||||
if err := userFlag.RunE(userFlag, nil); err == nil {
|
||||
t.Fatal("app personal flag succeeded")
|
||||
}
|
||||
if err := makeApp().RunE(makeApp(), []string{"event.key"}); err == nil {
|
||||
t.Fatal("app event key succeeded")
|
||||
}
|
||||
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
|
||||
if err := makeApp().RunE(makeApp(), nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("event credentials error = %v", err)
|
||||
}
|
||||
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
|
||||
badRoute := makeApp()
|
||||
_ = badRoute.Flags().Set("route", "bad")
|
||||
if err := badRoute.RunE(badRoute, nil); err == nil {
|
||||
t.Fatal("invalid event route succeeded")
|
||||
}
|
||||
invalidConfig := makeApp()
|
||||
_ = invalidConfig.Flags().Set("format", "json")
|
||||
if err := invalidConfig.RunE(invalidConfig, nil); err == nil {
|
||||
t.Fatal("unbounded JSON event stream succeeded")
|
||||
}
|
||||
conflict := makeApp()
|
||||
conflict.Flags().String("output", "", "")
|
||||
_ = conflict.Flags().Set("output-dir", t.TempDir())
|
||||
_ = conflict.Flags().Set("output", filepath.Join(t.TempDir(), "out"))
|
||||
if err := conflict.RunE(conflict, nil); err == nil {
|
||||
t.Fatal("event output conflict succeeded")
|
||||
}
|
||||
eventConsumeRun = func(context.Context, consume.Config) error { return fail }
|
||||
t.Setenv(authpkg.EnvClientID, "half-set")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
valid := makeApp()
|
||||
_ = valid.Flags().Set("format", "table")
|
||||
_ = valid.Flags().Set("max-events", "1")
|
||||
if err := valid.RunE(valid, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("event consume run error = %v", err)
|
||||
}
|
||||
eventRunForeground = func(context.Context, consume.Config, string, string, eventStreamTicketOptions) error { return fail }
|
||||
foreground := makeApp()
|
||||
_ = foreground.Flags().Set("foreground", "true")
|
||||
if err := foreground.RunE(foreground, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("foreground event error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
|
||||
oldNew, oldToken, oldEventSource, oldBus := eventNewDingtalkSource, eventResolveAccessToken, eventNewEventSource, eventBusRun
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
eventNewDingtalkSource, eventResolveAccessToken = oldNew, oldToken
|
||||
eventNewEventSource, eventBusRun = oldEventSource, oldBus
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) { return nil, fail }
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||
t.Fatalf("SDK event source error = %v", err)
|
||||
}
|
||||
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) {
|
||||
return &source.DingtalkSource{}, nil
|
||||
}
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
stream := eventStreamTicketOptions{Mode: "custom"}
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
|
||||
t.Fatalf("stream token error = %v", err)
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
|
||||
t.Fatal("empty stream token succeeded")
|
||||
}
|
||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||
for _, mode := range []string{"custom", "normal"} {
|
||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{Mode: mode}); err != nil {
|
||||
t.Fatalf("stream source %s = %v", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "test"})
|
||||
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
return nil, fail
|
||||
}
|
||||
if err := runForegroundBus(context.Background(), consume.Config{}, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||
t.Fatalf("foreground source error = %v", err)
|
||||
}
|
||||
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
return &source.DingtalkSource{}, nil
|
||||
}
|
||||
eventBusRun = func(context.Context, bus.Config) error { return fail }
|
||||
cfg := consume.Config{WorkDir: t.TempDir(), IPCEndpoint: filepath.Join(t.TempDir(), "bus.sock"), ClientID: "client"}
|
||||
if err := runForegroundBus(context.Background(), cfg, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||
t.Fatalf("foreground bus error = %v", err)
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.example.test"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_STREAM_SOURCE_ID", "")
|
||||
if got := defaultEventStreamSourceID(); got != "pre_open_source" {
|
||||
t.Fatalf("pre stream source ID = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventBusCommandAllBranchesCoverage(t *testing.T) {
|
||||
oldReady, oldPersonal, oldPersonalSource := eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource
|
||||
oldCreds, oldSource, oldRun := eventResolveCredentials, eventNewEventSource, eventBusRun
|
||||
oldMkdir, oldOpen := eventMkdirAll, eventOpenFile
|
||||
t.Cleanup(func() {
|
||||
eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource = oldReady, oldPersonal, oldPersonalSource
|
||||
eventResolveCredentials, eventNewEventSource, eventBusRun = oldCreds, oldSource, oldRun
|
||||
eventMkdirAll, eventOpenFile = oldMkdir, oldOpen
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
fail := errors.New("failure")
|
||||
makeBus := func(kind string) *cobra.Command {
|
||||
cmd := newEventBusCommand()
|
||||
cmd.SetContext(context.Background())
|
||||
_ = cmd.Flags().Set("source-kind", kind)
|
||||
return cmd
|
||||
}
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventReadyFDFromEnv = func() *os.File { return write }
|
||||
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, fail }
|
||||
if err := makeBus(string(dwsevent.SourceKindPersonalStream)).RunE(makeBus(string(dwsevent.SourceKindPersonalStream)), nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal identity error = %v", err)
|
||||
}
|
||||
marker := make([]byte, 1)
|
||||
_, _ = read.Read(marker)
|
||||
_ = read.Close()
|
||||
if marker[0] != 'E' {
|
||||
t.Fatalf("ready failure marker = %q", marker)
|
||||
}
|
||||
eventReadyFDFromEnv = func() *os.File { return nil }
|
||||
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, fail }
|
||||
personalCmd := makeBus(string(dwsevent.SourceKindPersonalStream))
|
||||
_ = personalCmd.Flags().Set("client-id", "override")
|
||||
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal stream source error = %v", err)
|
||||
}
|
||||
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return &source.PersonalSource{}, nil
|
||||
}
|
||||
eventMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
eventOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "bus-log") }
|
||||
eventBusRun = func(context.Context, bus.Config) error { return fail }
|
||||
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal bus run error = %v", err)
|
||||
}
|
||||
|
||||
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
|
||||
if err := makeBus(string(dwsevent.SourceKindAppStream)).RunE(makeBus(string(dwsevent.SourceKindAppStream)), nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app bus credentials error = %v", err)
|
||||
}
|
||||
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
|
||||
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
return nil, fail
|
||||
}
|
||||
appCmd := makeBus("")
|
||||
_ = appCmd.Flags().Set("client-id", "override")
|
||||
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app event source error = %v", err)
|
||||
}
|
||||
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||
return &source.DingtalkSource{}, nil
|
||||
}
|
||||
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app bus run error = %v", err)
|
||||
}
|
||||
eventMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app bus run after log mkdir failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListStatusCollectAndStopCoverage(t *testing.T) {
|
||||
oldList, oldStatus, oldStopPersonal := eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop
|
||||
oldEnum, oldFind, oldQuery, oldStop := eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus
|
||||
oldCreds := eventResolveAppCredentials
|
||||
oldNormalize := eventNormalizeAs
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop = oldList, oldStatus, oldStopPersonal
|
||||
eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus = oldEnum, oldFind, oldQuery, oldStop
|
||||
eventResolveAppCredentials = oldCreds
|
||||
eventNormalizeAs = oldNormalize
|
||||
})
|
||||
eventNormalizeAs = func(value string) (string, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "app") {
|
||||
return "app", nil
|
||||
}
|
||||
return normalizeEventAs(value)
|
||||
}
|
||||
fail := errors.New("failure")
|
||||
eventRunPersonalList = func(*cobra.Command, personalListOptions) error { return fail }
|
||||
list := newEventListCommand()
|
||||
list.SetOut(io.Discard)
|
||||
if err := list.RunE(list, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal list error = %v", err)
|
||||
}
|
||||
eventRunPersonalStatus = func(*cobra.Command, personalStatusOptions) error { return fail }
|
||||
status := newEventStatusCommand()
|
||||
status.SetOut(io.Discard)
|
||||
if err := status.RunE(status, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal status error = %v", err)
|
||||
}
|
||||
eventRunPersonalStop = func(*cobra.Command, personalStopOptions) error { return fail }
|
||||
stop := newEventStopCommand()
|
||||
stop.SetOut(io.Discard)
|
||||
stop.Flags().Bool("yes", false, "")
|
||||
_ = stop.Flags().Set("yes", "true")
|
||||
if err := stop.RunE(stop, []string{"sub"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("personal stop error = %v", err)
|
||||
}
|
||||
|
||||
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) { return nil, fail }
|
||||
if _, err := collectEntries(&cobra.Command{}, "client", false, true); !errors.Is(err, fail) {
|
||||
t.Fatalf("all-editions collect error = %v", err)
|
||||
}
|
||||
if _, err := collectEntries(&cobra.Command{}, "client", true, false); !errors.Is(err, fail) {
|
||||
t.Fatalf("all collect error = %v", err)
|
||||
}
|
||||
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) {
|
||||
return []busctl.BusEntry{{ClientIDHash: "hash"}}, nil
|
||||
}
|
||||
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||
if got, err := collectEntries(&cobra.Command{}, "client", false, true); err != nil || len(got) != 1 {
|
||||
t.Fatalf("all-editions entries = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := collectEntries(&cobra.Command{}, "client", true, false); err != nil || len(got) != 1 {
|
||||
t.Fatalf("all entries = %#v, %v", got, err)
|
||||
}
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||
}
|
||||
if _, err := collectEntries(&cobra.Command{}, "", false, false); !errors.Is(err, fail) {
|
||||
t.Fatalf("single credentials error = %v", err)
|
||||
}
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||
}
|
||||
eventFindBus = func(string, string, string) *busctl.BusEntry { return nil }
|
||||
if got, err := collectEntries(&cobra.Command{}, "", false, false); err != nil || len(got) != 1 || got[0].Entry.State != busctl.BusStateNotRunning {
|
||||
t.Fatalf("missing bus entry = %#v, %v", got, err)
|
||||
}
|
||||
eventFindBus = func(string, string, string) *busctl.BusEntry { return &busctl.BusEntry{} }
|
||||
if got, err := collectEntries(&cobra.Command{}, "client", false, false); err != nil || len(got) != 1 || got[0].Entry.Meta == nil {
|
||||
t.Fatalf("found bus entry = %#v, %v", got, err)
|
||||
}
|
||||
|
||||
appList := newEventListCommand()
|
||||
appList.SetOut(io.Discard)
|
||||
_ = appList.Flags().Set("as", "app")
|
||||
_ = appList.Flags().Set("client-id", "client")
|
||||
if err := appList.RunE(appList, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rejectedList := newEventListCommand()
|
||||
_ = rejectedList.Flags().Set("as", "app")
|
||||
_ = rejectedList.Flags().Set("category", "chat")
|
||||
if err := rejectedList.RunE(rejectedList, nil); err == nil {
|
||||
t.Fatal("app list personal flag succeeded")
|
||||
}
|
||||
eventFindBus = func(string, string, string) *busctl.BusEntry {
|
||||
return &busctl.BusEntry{State: busctl.BusStateOrphan, Meta: &bus.Meta{ClientID: "client"}}
|
||||
}
|
||||
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||
appStatus := newEventStatusCommand()
|
||||
appStatus.SetOut(io.Discard)
|
||||
_ = appStatus.Flags().Set("as", "app")
|
||||
_ = appStatus.Flags().Set("client-id", "client")
|
||||
_ = appStatus.Flags().Set("fail-on-orphan", "true")
|
||||
if err := appStatus.RunE(appStatus, nil); err == nil {
|
||||
t.Fatal("orphan event status succeeded")
|
||||
}
|
||||
rejectedStatus := newEventStatusCommand()
|
||||
_ = rejectedStatus.Flags().Set("as", "app")
|
||||
_ = rejectedStatus.Flags().Set("event", "key")
|
||||
if err := rejectedStatus.RunE(rejectedStatus, nil); err == nil {
|
||||
t.Fatal("app status personal flag succeeded")
|
||||
}
|
||||
|
||||
appStop := func() *cobra.Command {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.Flags().Bool("yes", true, "")
|
||||
_ = cmd.Flags().Set("as", "app")
|
||||
return cmd
|
||||
}
|
||||
changedStop := appStop()
|
||||
_ = changedStop.Flags().Set("all", "true")
|
||||
if err := changedStop.RunE(changedStop, nil); err == nil {
|
||||
t.Fatal("app stop personal flag succeeded")
|
||||
}
|
||||
if err := appStop().RunE(appStop(), []string{"sub"}); err == nil {
|
||||
t.Fatal("app subscribe ID succeeded")
|
||||
}
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||
}
|
||||
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app stop credentials error = %v", err)
|
||||
}
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||
}
|
||||
eventStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
|
||||
if err := appStop().RunE(appStop(), nil); err != nil {
|
||||
t.Fatalf("already stopped bus = %v", err)
|
||||
}
|
||||
eventStopBus = func(busctl.StopConfig) error { return fail }
|
||||
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("stop bus error = %v", err)
|
||||
}
|
||||
eventStopBus = func(busctl.StopConfig) error { return nil }
|
||||
if err := appStop().RunE(appStop(), nil); err != nil {
|
||||
t.Fatalf("stop bus success = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandParentCoverage(t *testing.T) {
|
||||
cmd := newEventCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cmd.Use, "event") {
|
||||
t.Fatal("event command use changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureAndRenderBranchesCoverage(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "client")
|
||||
t.Setenv(authpkg.EnvClientSecret, "secret")
|
||||
if got := (eventStreamTicketOptions{Mode: "custom", SourceID: " source ", TicketURL: " https://ticket "}).spawnArgs(); len(got) != 6 {
|
||||
t.Fatalf("stream spawn args = %#v", got)
|
||||
}
|
||||
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{}); err != nil || id != "client" || secret != "secret" {
|
||||
t.Fatalf("app credentials = %q, %q, %v", id, secret, err)
|
||||
}
|
||||
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{Mode: "normal", SourceID: "source"}); err != nil || id != "portal-ticket-normal:source" || secret != "" {
|
||||
t.Fatalf("portal credentials = %q, %q, %v", id, secret, err)
|
||||
}
|
||||
if eventStreamTicketURL(" https://ticket ") != "https://ticket" || eventStreamSourceID(" source ") != "source" {
|
||||
t.Fatal("explicit event stream routing changed")
|
||||
}
|
||||
t.Setenv("DWS_STREAM_SOURCE_ID", "environment")
|
||||
if defaultEventStreamSourceID() != "environment" {
|
||||
t.Fatal("environment stream source ID ignored")
|
||||
}
|
||||
|
||||
oldEdition := edition.Get()
|
||||
edition.Override(&edition.Hooks{})
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
if editionNameOrDefault() != "open" || sourceKindLabel("") != string(dwsevent.SourceKindAppStream) {
|
||||
t.Fatal("default event labels changed")
|
||||
}
|
||||
if !strings.Contains(eventWorkDir("config", "open", "", "hash"), string(dwsevent.SourceKindAppStream)) {
|
||||
t.Fatal("default event workdir changed")
|
||||
}
|
||||
if _, err := normalizeEventAs("bot"); err == nil {
|
||||
t.Fatal("bot events became public unexpectedly")
|
||||
}
|
||||
flags := &cobra.Command{}
|
||||
flags.Flags().Bool("all", false, "")
|
||||
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = flags.Flags().Set("all", "true")
|
||||
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err == nil {
|
||||
t.Fatal("personal unsupported flag accepted")
|
||||
}
|
||||
if firstArg(nil) != "" || firstArg([]string{"value"}) != "value" || len(eventTypesWithDefault([]string{"type"})) != 1 {
|
||||
t.Fatal("event helper defaults changed")
|
||||
}
|
||||
_ = eventTypesWithDefault(nil)
|
||||
|
||||
live := &eventtransport.StatusResp{
|
||||
Bus: eventtransport.StatusBus{UptimeSecs: 12},
|
||||
SourceState: eventtransport.StatusSource{State: "connected", Source: "hook", ReconnectCount: 1},
|
||||
Consumers: []eventtransport.StatusConsumer{
|
||||
{PID: 1, Received: 2, Dropped: 3},
|
||||
{PID: 2, EventTypes: []string{"chat"}, SubscribeID: "sub"},
|
||||
},
|
||||
PerEventTypeCounters: map[string]eventtransport.Counters{"chat": {Received: 2, Dropped: 1}},
|
||||
}
|
||||
entries := []busctl.EntryStatus{
|
||||
{Entry: busctl.BusEntry{State: busctl.BusStateNotRunning, ClientIDHash: "not-running"}},
|
||||
{Entry: busctl.BusEntry{State: busctl.BusStateOrphan, HolderPID: 3, Meta: &bus.Meta{ClientID: "orphan", SourceID: "source", StartedAt: time.Now()}}},
|
||||
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 4, Meta: &bus.Meta{ClientID: "offline", StartedAt: time.Now().Add(-time.Minute)}}},
|
||||
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 5, Meta: &bus.Meta{ClientID: "live"}}, Live: live},
|
||||
}
|
||||
if err := renderStatus(io.Discard, entries, "text"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renderStatus(io.Discard, entries, "json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renderStatus(appFailWriter{err: errors.New("write")}, entries, "json"); err == nil {
|
||||
t.Fatal("status JSON write failure succeeded")
|
||||
}
|
||||
listEntries := []listEntry{
|
||||
{ClientIDHash: "hash", BusState: busctl.BusStateNotRunning},
|
||||
{ClientID: "client", SourceKind: dwsevent.SourceKindPersonalStream, BusState: busctl.BusStateRunning, BusPID: 2, Consumers: live.Consumers},
|
||||
}
|
||||
if err := renderList(io.Discard, listEntries, "table"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renderList(io.Discard, listEntries, "json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renderList(appFailWriter{err: errors.New("write")}, listEntries, "json"); err == nil {
|
||||
t.Fatal("list JSON write failure succeeded")
|
||||
}
|
||||
if got := buildListEntry(busctl.EntryStatus{Entry: busctl.BusEntry{Meta: &bus.Meta{ClientID: "client"}}, Live: live}); len(got.Consumers) != 2 {
|
||||
t.Fatalf("live list entry = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandClosureErrorBranchesCoverage(t *testing.T) {
|
||||
oldNormalize := eventNormalizeAs
|
||||
oldList, oldStatus := eventRunPersonalList, eventRunPersonalStatus
|
||||
oldCreds, oldFind, oldQuery := eventResolveAppCredentials, eventFindBus, eventQueryEntry
|
||||
t.Cleanup(func() {
|
||||
eventNormalizeAs = oldNormalize
|
||||
eventRunPersonalList, eventRunPersonalStatus = oldList, oldStatus
|
||||
eventResolveAppCredentials, eventFindBus, eventQueryEntry = oldCreds, oldFind, oldQuery
|
||||
})
|
||||
eventNormalizeAs = func(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "app" {
|
||||
return "app", nil
|
||||
}
|
||||
return normalizeEventAs(value)
|
||||
}
|
||||
personalList := newEventListCommand()
|
||||
_ = personalList.Flags().Set("all", "true")
|
||||
if err := personalList.RunE(personalList, nil); err == nil {
|
||||
t.Fatal("personal list app flag succeeded")
|
||||
}
|
||||
personalStatus := newEventStatusCommand()
|
||||
_ = personalStatus.Flags().Set("fail-on-orphan", "true")
|
||||
if err := personalStatus.RunE(personalStatus, nil); err == nil {
|
||||
t.Fatal("personal status app flag succeeded")
|
||||
}
|
||||
fail := errors.New("failure")
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||
}
|
||||
appList := newEventListCommand()
|
||||
_ = appList.Flags().Set("as", "app")
|
||||
if err := appList.RunE(appList, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app list collect error = %v", err)
|
||||
}
|
||||
appStatus := newEventStatusCommand()
|
||||
_ = appStatus.Flags().Set("as", "app")
|
||||
if err := appStatus.RunE(appStatus, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("app status collect error = %v", err)
|
||||
}
|
||||
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||
}
|
||||
eventFindBus = func(string, string, string) *busctl.BusEntry {
|
||||
return &busctl.BusEntry{State: busctl.BusStateRunning, Meta: &bus.Meta{ClientID: "client"}}
|
||||
}
|
||||
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||
appStatus = newEventStatusCommand()
|
||||
appStatus.SetOut(io.Discard)
|
||||
_ = appStatus.Flags().Set("as", "app")
|
||||
_ = appStatus.Flags().Set("fail-on-orphan", "true")
|
||||
if err := appStatus.RunE(appStatus, nil); err != nil {
|
||||
t.Fatalf("healthy status with orphan gate = %v", err)
|
||||
}
|
||||
appStatus = newEventStatusCommand()
|
||||
appStatus.SetOut(appFailWriter{err: errors.New("write")})
|
||||
_ = appStatus.Flags().Set("as", "app")
|
||||
_ = appStatus.Flags().Set("format", "json")
|
||||
if err := appStatus.RunE(appStatus, nil); err == nil {
|
||||
t.Fatal("status render failure should propagate")
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,33 @@ type personalStreamSourceOptions struct {
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
var (
|
||||
personalResolveEventIdentity = resolvePersonalEventIdentity
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = (*personal.Client).GetSubscription
|
||||
personalCreateSubscription = (*personal.Client).CreateSubscription
|
||||
personalDeleteSubscription = (*personal.Client).DeleteSubscription
|
||||
personalListSubscriptions = (*personal.Client).ListSubscriptions
|
||||
personalUpsertRunState = personal.UpsertRunState
|
||||
personalRemoveRunStates = personal.RemoveRunStates
|
||||
personalLoadRunStates = personal.LoadRunStates
|
||||
personalConsumeRun = consume.Run
|
||||
personalValidateConsumeConfig = consume.ValidateConfig
|
||||
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
|
||||
personalNewStreamSource = newPersonalStreamSource
|
||||
personalBusRun = bus.Run
|
||||
personalFindBusByIdentity = busctl.FindBusByIdentity
|
||||
personalQueryEntry = busctl.QueryEntry
|
||||
personalQueryStatus = busctl.QueryStatus
|
||||
personalStopBus = busctl.Stop
|
||||
personalFindProcess = os.FindProcess
|
||||
personalSignalProcess = (*os.Process).Signal
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
personalLoadTokenData = authpkg.LoadTokenData
|
||||
personalClientID = authpkg.ClientID
|
||||
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
|
||||
)
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
@@ -116,13 +143,10 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
_, err := normalizeEventAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as != "user" {
|
||||
return fmt.Errorf("event schema is only supported with --as user")
|
||||
}
|
||||
def, ok := personal.Lookup(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown personal event key %q", args[0])
|
||||
@@ -181,7 +205,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -223,18 +247,18 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return consume.Run(ctx, cfg)
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
}
|
||||
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
@@ -245,8 +269,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
@@ -279,24 +303,22 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
||||
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
@@ -319,13 +341,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Source: src,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = bus.Run(ctx, busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = consume.Run(ctx, cfg)
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
@@ -349,7 +371,7 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
|
||||
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
@@ -399,7 +421,7 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := client.CreateSubscription(ctx, req)
|
||||
sub, err := personalCreateSubscription(client, ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
@@ -412,17 +434,17 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
entry := personalFindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
var qs busctl.EntryStatus
|
||||
if entry != nil {
|
||||
qs = busctl.QueryEntry(*entry)
|
||||
qs = personalQueryEntry(*entry)
|
||||
} else {
|
||||
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
||||
WorkDir: workDir,
|
||||
@@ -444,7 +466,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
|
||||
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
@@ -547,7 +569,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
@@ -561,18 +583,18 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := client.DeleteSubscription(ctx, id); err != nil {
|
||||
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
remaining, err := personal.LoadRunStates(workDir)
|
||||
remaining, err := personalLoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
||||
}
|
||||
@@ -582,7 +604,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
@@ -604,7 +626,7 @@ func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
|
||||
if !all {
|
||||
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
||||
}
|
||||
states, err := personal.LoadRunStates(workDir)
|
||||
states, err := personalLoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -629,7 +651,7 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
if ipcEndpoint == "" || len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
status, err := busctl.QueryStatus(ipcEndpoint)
|
||||
status, err := personalQueryStatus(ipcEndpoint)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -644,11 +666,11 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
if _, ok := signalled[consumer.PID]; ok {
|
||||
continue
|
||||
}
|
||||
proc, err := os.FindProcess(consumer.PID)
|
||||
proc, err := personalFindProcess(consumer.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
if err := personalSignalProcess(proc, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
signalled[consumer.PID] = struct{}{}
|
||||
@@ -665,11 +687,11 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return personal.Identity{}, err
|
||||
}
|
||||
tokenData, _ := authpkg.LoadTokenData(configDir)
|
||||
tokenData, _ := personalLoadTokenData(configDir)
|
||||
var corpID, userID, clientID, refreshToken string
|
||||
if tokenData != nil {
|
||||
corpID = tokenData.CorpID
|
||||
@@ -684,10 +706,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = authpkg.ClientID()
|
||||
clientID = personalClientID()
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
|
||||
if id, _, _, _, err := personalResolveAppCredentialsStrict(configDir); err == nil {
|
||||
clientID = id
|
||||
}
|
||||
}
|
||||
@@ -750,7 +772,7 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
clientID := opts.Identity.ClientID
|
||||
clientSecret := ""
|
||||
if mode == "custom" {
|
||||
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
|
||||
resolvedID, secret, _, _, err := personalResolveAppCredentialsStrict(opts.ConfigDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -852,10 +874,7 @@ func personalEventStreamSourceID(raw string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
|
||||
return v
|
||||
}
|
||||
return "open"
|
||||
return strings.TrimSpace(edition.PersonalEventSourceID())
|
||||
}
|
||||
|
||||
func personalEventMCPBaseURL(configDir string) string {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverage(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"known", "--as", "app"},
|
||||
{"not-a-real-event"},
|
||||
} {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("schema args %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldCreate := personalCreateSubscription
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalCreateSubscription = oldCreate
|
||||
})
|
||||
client := personal.NewClient("https://example.test", personal.Identity{})
|
||||
wantErr := errors.New("subscription")
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("get subscription error = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{}, nil
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
|
||||
t.Fatal("empty subscription event key succeeded")
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{EventKey: personal.EventFromUser}, nil
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
|
||||
t.Fatal("private subscription event succeeded")
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{EventKey: personal.EventMention}, nil
|
||||
}
|
||||
if _, _, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || rule == "" {
|
||||
t.Fatalf("default subscription rule = %q, %v", rule, err)
|
||||
}
|
||||
personalCreateSubscription = func(*personal.Client, context.Context, personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("create subscription error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldConsume := personalConsumeRun
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalConsumeRun = oldConsume
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
|
||||
wantErr := errors.New("consume")
|
||||
cmd := newPersonalCoverageCommand()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("identity error = %v", err)
|
||||
}
|
||||
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{RoutesRaw: []string{"bad-route"}}}); err == nil {
|
||||
t.Fatal("invalid route succeeded")
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
|
||||
_ = cmd.Flags().Set("format", "table")
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{DryRun: true, FormatRaw: "bogus"}}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("dry-run consume error = %v", err)
|
||||
}
|
||||
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ensure subscription error = %v", err)
|
||||
}
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{}, personal.EventMention, "at", nil
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); err == nil || !strings.Contains(err.Error(), "empty subscribe_id") {
|
||||
t.Fatalf("empty subscription = %v", err)
|
||||
}
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("state upsert error = %v", err)
|
||||
}
|
||||
|
||||
deletes := 0
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { deletes++; return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, DebugRawEvents: true}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("validate error = %v", err)
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
_ = cmd.Flags().Set("output", "file")
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("output conflict = %v", err)
|
||||
}
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
|
||||
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
before := deletes
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
|
||||
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
|
||||
personalBusRun = func(context.Context, bus.Config) error { return wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("bus run error = %v", err)
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("background consume error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingStatusStopAndInterruptCoverage(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldFindBus := personalFindBusByIdentity
|
||||
oldQueryEntry := personalQueryEntry
|
||||
oldList := personalListSubscriptions
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldLoad := personalLoadRunStates
|
||||
oldStop := personalStopBus
|
||||
oldQueryStatus := personalQueryStatus
|
||||
oldFindProcess := personalFindProcess
|
||||
oldSignal := personalSignalProcess
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalFindBusByIdentity = oldFindBus
|
||||
personalQueryEntry = oldQueryEntry
|
||||
personalListSubscriptions = oldList
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalLoadRunStates = oldLoad
|
||||
personalStopBus = oldStop
|
||||
personalQueryStatus = oldQueryStatus
|
||||
personalFindProcess = oldFindProcess
|
||||
personalSignalProcess = oldSignal
|
||||
})
|
||||
|
||||
wantErr := errors.New("status-stop")
|
||||
cmd := newPersonalCoverageCommand()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
|
||||
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("status identity error = %v", err)
|
||||
}
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("stop identity error = %v", err)
|
||||
}
|
||||
identity := personal.Identity{ClientID: "client", SourceID: "source"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||
entry := &busctl.BusEntry{State: busctl.BusStateRunning}
|
||||
personalFindBusByIdentity = func(string, string, dwsevent.SourceKind, string) *busctl.BusEntry { return entry }
|
||||
personalQueryEntry = func(busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: *entry} }
|
||||
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("status list error = %v", err)
|
||||
}
|
||||
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
if err := runPersonalEventStatus(cmd, personalStatusOptions{Status: "all", Format: "json"}); err != nil {
|
||||
t.Fatalf("status entry JSON = %v", err)
|
||||
}
|
||||
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{All: true}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("stop targets error = %v", err)
|
||||
}
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return wantErr }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("delete error = %v", err)
|
||||
}
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return wantErr }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("remove error = %v", err)
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return nil, wantErr }
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("remaining state error = %v", err)
|
||||
}
|
||||
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) {
|
||||
return &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{{SubscribeID: "sub", PID: 123}}}, nil
|
||||
}
|
||||
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||
t.Fatalf("interrupt warning stop = %v", err)
|
||||
}
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||
t.Fatalf("remaining bus stop = %v", err)
|
||||
}
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, nil }
|
||||
personalStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||
t.Fatalf("not running stop = %v", err)
|
||||
}
|
||||
personalStopBus = func(busctl.StopConfig) error { return wantErr }
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("bus stop error = %v", err)
|
||||
}
|
||||
|
||||
personalLoadRunStates = func(string) ([]personal.RunState, error) {
|
||||
return []personal.RunState{{}, {SubscribeID: "b"}, {SubscribeID: "a"}}, nil
|
||||
}
|
||||
if got, err := personalStopTargets("", "", true); err != nil || strings.Join(got, ",") != "a,b" {
|
||||
t.Fatalf("stop target filtering = %#v, %v", got, err)
|
||||
}
|
||||
status := &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{
|
||||
{SubscribeID: "other", PID: 1},
|
||||
{SubscribeID: "sub", PID: 0},
|
||||
{SubscribeID: "sub", PID: os.Getpid()},
|
||||
{SubscribeID: "sub", PID: 123},
|
||||
{SubscribeID: "sub", PID: 123},
|
||||
}}
|
||||
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return status, nil }
|
||||
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
|
||||
if err := interruptPersonalConsumers("ipc", []string{" sub ", ""}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("find process error = %v", err)
|
||||
}
|
||||
proc := &os.Process{}
|
||||
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
personalSignalProcess = func(*os.Process, os.Signal) error { return wantErr }
|
||||
if err := interruptPersonalConsumers("ipc", []string{"sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("signal process error = %v", err)
|
||||
}
|
||||
personalSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
|
||||
if err := interruptPersonalConsumers("ipc", []string{"sub"}); err != nil {
|
||||
t.Fatalf("completed process signal = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingIdentityAndSourceCoverage(t *testing.T) {
|
||||
oldAux := personalResolveAuxiliaryAccessToken
|
||||
oldLoad := personalLoadTokenData
|
||||
oldClientID := personalClientID
|
||||
oldCredentials := personalResolveAppCredentialsStrict
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
personalResolveAuxiliaryAccessToken = oldAux
|
||||
personalLoadTokenData = oldLoad
|
||||
personalClientID = oldClientID
|
||||
personalResolveAppCredentialsStrict = oldCredentials
|
||||
edition.Override(oldEdition)
|
||||
})
|
||||
wantErr := errors.New("identity")
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "", wantErr }
|
||||
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("aux token error = %v", err)
|
||||
}
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "access", nil }
|
||||
personalLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||
personalClientID = func() string { return "" }
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "resolved", "secret", "", "", nil
|
||||
}
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return " corp ", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return " user ", true },
|
||||
}
|
||||
}})
|
||||
if got, err := resolvePersonalEventIdentity(context.Background(), "", "source"); err != nil || got.ClientID != "resolved" || got.CorpID != "corp" {
|
||||
t.Fatalf("resolved identity = %#v, %v", got, err)
|
||||
}
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", "", "", wantErr
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); err == nil {
|
||||
t.Fatal("missing client ID succeeded")
|
||||
}
|
||||
if got := resolveRuntimeDefault(context.Background(), "missing"); got != "" {
|
||||
t.Fatalf("missing runtime default = %q", got)
|
||||
}
|
||||
|
||||
if _, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: "", Identity: personal.Identity{}, TicketMode: "custom"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("custom credential error = %v", err)
|
||||
}
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "resolved", "secret", "", "", nil
|
||||
}
|
||||
if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{Identity: personal.Identity{AccessToken: "token", SourceID: "source"}, TicketMode: "custom"}); err != nil || src == nil {
|
||||
t.Fatalf("custom resolved source = %#v, %v", src, err)
|
||||
}
|
||||
if got := personalEventStreamSourceID(""); got != "open" {
|
||||
t.Fatalf("default stream source = %q", got)
|
||||
}
|
||||
if got := configuredMCPBaseURL(""); got != "" {
|
||||
t.Fatalf("default missing configured MCP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersonalCoverageCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "event"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.Flags().String("format", "table", "")
|
||||
cmd.Flags().String("output", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
@@ -23,6 +23,20 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
type accessTokenGetter interface {
|
||||
GetAccessToken(context.Context) (string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
markAccessTokenStale = authpkg.MarkAccessTokenStale
|
||||
newRefreshProvider = func(configDir string) accessTokenGetter {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
return provider
|
||||
}
|
||||
)
|
||||
|
||||
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
||||
// the new access_token. It is intended for callers that have observed a
|
||||
// server-side rejection (HTTP 401 or business code such as
|
||||
@@ -43,12 +57,10 @@ func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, err
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
|
||||
if err := markAccessTokenStale(configDir); err != nil {
|
||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||
}
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
provider := newRefreshProvider(configDir)
|
||||
tok, err := provider.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -28,6 +31,17 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller)
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
// built-ins. Conflicts with built-ins are skipped inside Load.
|
||||
if _, err := userdef.Load(); err != nil {
|
||||
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
|
||||
}
|
||||
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
|
||||
// command tree; mergeTopLevelCommands folds each shortcut's service parent
|
||||
// into the matching helper command so the `+leaf` sits alongside existing
|
||||
// subcommands.
|
||||
commands = append(commands, builtin.Commands()...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,26 @@ const (
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
|
||||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||||
patOrgPolicyDeniedCode = "PAT_ORG_POLICY_DENIED"
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
var (
|
||||
patAuthorizationTimeout = PatAuthRetryTimeout
|
||||
patAuthorizationPollInterval = PatAuthPollInterval
|
||||
patLoadTokenData = authpkg.LoadTokenData
|
||||
patWaitForAuthorization = WaitForPatAuthorization
|
||||
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||
patSaveAppConfig = authpkg.SaveAppConfig
|
||||
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
|
||||
patSaveTokenData = authpkg.SaveTokenData
|
||||
patSleep = time.Sleep
|
||||
patPollHTTPDo = (*http.Client).Do
|
||||
patPollNewRequest = http.NewRequestWithContext
|
||||
patBrowserOpenCommand = browserOpenCommand
|
||||
)
|
||||
|
||||
type patSuppressBrowserOpenKeyType struct{}
|
||||
|
||||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||||
@@ -234,12 +250,13 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
if code, _ := payload["code"].(string); code == "PAT_ORG_POLICY_DENIED" {
|
||||
data["openBrowser"] = false
|
||||
} else {
|
||||
data["openBrowser"] = openBrowser
|
||||
}
|
||||
|
||||
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
@@ -256,9 +273,9 @@ func patAuthorizationURIFromData(data map[string]any) string {
|
||||
// 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 {
|
||||
timeout := PatAuthRetryTimeout
|
||||
timeout := patAuthorizationTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
pollTicker := time.NewTicker(patAuthorizationPollInterval)
|
||||
defer pollTicker.Stop()
|
||||
start := time.Now()
|
||||
|
||||
@@ -286,7 +303,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
remaining := time.Until(deadline).Truncate(time.Second)
|
||||
|
||||
// Check if token is now valid
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
tokenData, err := patLoadTokenData(configDir)
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
@@ -323,7 +340,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
// Wait for user to complete authorization
|
||||
authorized := WaitForPatAuthorization(ctx, configDir, output)
|
||||
authorized := patWaitForAuthorization(ctx, configDir, output)
|
||||
if !authorized {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"等待用户授权超时",
|
||||
@@ -500,6 +517,15 @@ func handlePatAuthCheck(
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthorizationURL
|
||||
}
|
||||
// Organization-policy denial is terminal until an administrator changes
|
||||
// the policy. Return it before reading browser policy, mutating process-wide
|
||||
// credentials, opening a browser, polling, or retrying the invocation even
|
||||
// when a lenient backend also supplies active-flow fields.
|
||||
if patData.Code == patOrgPolicyDeniedCode {
|
||||
return executor.Result{}, &apperrors.PATError{
|
||||
RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, false),
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
@@ -579,7 +605,7 @@ func handlePatAuthCheck(
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
||||
status, authCode, err := patPollDeviceFlowWithInterval(
|
||||
pollCtx, patData.Data.FlowID, configDir, output,
|
||||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||||
)
|
||||
@@ -594,7 +620,7 @@ func handlePatAuthCheck(
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
if err := patSaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
@@ -603,12 +629,12 @@ func handlePatAuthCheck(
|
||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||
if authCode != "" {
|
||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
||||
tokenData, exchErr := patExchangeCodeForToken(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", tui.StateMark("warning"), exchErr)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
if err := patSaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||||
} else {
|
||||
@@ -633,7 +659,7 @@ func handlePatAuthCheck(
|
||||
// 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)
|
||||
patSleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
@@ -705,10 +731,7 @@ func enrichPATErrorForHostControl(raw string) string {
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
@@ -739,10 +762,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := jsonutil.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
b, _ := jsonutil.Marshal(payload)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -803,7 +823,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
req, err := patPollNewRequest(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||
continue
|
||||
@@ -811,7 +831,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
||||
if accessToken != "" {
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
}
|
||||
resp, err := noRedirectClient.Do(req)
|
||||
resp, err := patPollHTTPDo(noRedirectClient, req)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: request failed", "error", err)
|
||||
continue // transient network error, keep polling
|
||||
@@ -858,9 +878,6 @@ func resolvePATPollInterval(seconds int) time.Duration {
|
||||
return patPollInterval
|
||||
}
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if interval > patMaxPollInterval {
|
||||
return patMaxPollInterval
|
||||
}
|
||||
@@ -882,7 +899,7 @@ func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
|
||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(rawURL string) error {
|
||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||||
cmd := patBrowserOpenCommand(runtime.GOOS, rawURL)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T) {
|
||||
typed := apperrors.NewAPI("ordinary", apperrors.WithHint("insufficient_scope"))
|
||||
if !isPatScopeError(typed) {
|
||||
t.Fatal("typed insufficient_scope was not recognized")
|
||||
}
|
||||
typed = apperrors.NewAPI("ordinary", apperrors.WithReason("reason"))
|
||||
if got := extractPatScopeError(typed); !strings.Contains(got.Message, "reason") {
|
||||
t.Fatalf("typed scope message = %q", got.Message)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
PrintPatAuthError(&out, &PatScopeError{Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login"})
|
||||
if !strings.Contains(out.String(), "dws auth login") {
|
||||
t.Fatalf("PAT output = %q", out.String())
|
||||
}
|
||||
if wantsStructuredPATOutputFromRunner(runnerCoverageFallback{}) {
|
||||
t.Fatal("non-runtime runner requested structured PAT output")
|
||||
}
|
||||
if got := enrichPATErrorWithOpenBrowser("", true); got != "" {
|
||||
t.Fatalf("empty enriched PAT = %q", got)
|
||||
}
|
||||
if got := enrichPATErrorWithOpenBrowser("not-json", true); got != "not-json" {
|
||||
t.Fatalf("malformed enriched PAT = %q", got)
|
||||
}
|
||||
if got := enrichPATErrorWithOpenBrowser(`{"code":"x"}`, true); !strings.Contains(got, "openBrowser") {
|
||||
t.Fatalf("missing data enrichment = %q", got)
|
||||
}
|
||||
if got := patAuthorizationURIFromData(map[string]any{"authorizationUrl": " final "}); got != "final" {
|
||||
t.Fatalf("authorization URI = %q", got)
|
||||
}
|
||||
if err := openPATAuthorizationURI(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldTimeout := patAuthorizationTimeout
|
||||
oldInterval := patAuthorizationPollInterval
|
||||
oldLoad := patLoadTokenData
|
||||
t.Cleanup(func() {
|
||||
patAuthorizationTimeout = oldTimeout
|
||||
patAuthorizationPollInterval = oldInterval
|
||||
patLoadTokenData = oldLoad
|
||||
})
|
||||
patAuthorizationTimeout = 50 * time.Millisecond
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
out.Reset()
|
||||
if !WaitForPatAuthorization(context.Background(), "", &out) {
|
||||
t.Fatal("valid token did not authorize")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(ctx, "", &out) {
|
||||
t.Fatal("cancelled authorization succeeded")
|
||||
}
|
||||
patAuthorizationTimeout = time.Millisecond
|
||||
patAuthorizationPollInterval = time.Hour
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(context.Background(), "", &out) {
|
||||
t.Fatal("timed out authorization succeeded")
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||
out.Reset()
|
||||
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.T) {
|
||||
oldWait := patWaitForAuthorization
|
||||
oldPoll := patPollDeviceFlowWithInterval
|
||||
oldSaveApp := patSaveAppConfig
|
||||
oldExchange := patExchangeCodeForToken
|
||||
oldSaveToken := patSaveTokenData
|
||||
oldSleep := patSleep
|
||||
oldOpen := openBrowserFunc
|
||||
t.Cleanup(func() {
|
||||
patWaitForAuthorization = oldWait
|
||||
patPollDeviceFlowWithInterval = oldPoll
|
||||
patSaveAppConfig = oldSaveApp
|
||||
patExchangeCodeForToken = oldExchange
|
||||
patSaveTokenData = oldSaveToken
|
||||
patSleep = oldSleep
|
||||
openBrowserFunc = oldOpen
|
||||
})
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
|
||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
|
||||
t.Fatal("PAT retry timeout succeeded")
|
||||
}
|
||||
wantErr := errors.New("runner failed")
|
||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
|
||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("authorized retry = %v", err)
|
||||
}
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||
if err := runDirectPATAuthCheck(context.Background(), nil, patErr, nil, io.Discard); !errors.Is(err, patErr) {
|
||||
t.Fatalf("nil direct retry = %v", err)
|
||||
}
|
||||
if err := runDirectPATAuthCheckWithMode(context.Background(), nil, patErr, nil, io.Discard, true); !errors.Is(err, patErr) {
|
||||
t.Fatalf("nil retry mode = %v", err)
|
||||
}
|
||||
|
||||
badRetry := errors.New("retry callback")
|
||||
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||
return authpkg.StatusApproved, "", nil
|
||||
}
|
||||
patSleep = func(time.Duration) {}
|
||||
if err := runDirectPATAuthCheck(context.Background(), nil, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, func(context.Context) error { return badRetry }, io.Discard); !errors.Is(err, badRetry) {
|
||||
t.Fatalf("direct retry callback = %v", err)
|
||||
}
|
||||
|
||||
malformedPAT := &apperrors.PATError{RawJSON: `{`}
|
||||
if _, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, executor.Invocation{}, malformedPAT, t.TempDir(), io.Discard); !errors.Is(err, malformedPAT) {
|
||||
t.Fatalf("malformed PAT handler = %v", err)
|
||||
}
|
||||
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
for _, raw := range []string{
|
||||
`{"code":"x","data":{"flowId":"f","authUrl":"https://auth.test","desc":"authorize"}}`,
|
||||
`{"code":"x","data":{"flowId":"f","authorizationUrl":"https://auth2.test"}}`,
|
||||
} {
|
||||
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||
return "", "", wantErr
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), patSuppressBrowserOpenKey, true)
|
||||
if _, err := handlePatAuthCheck(ctx, &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: raw}, t.TempDir(), io.Discard); err == nil {
|
||||
t.Fatal("poll failure returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
statuses := []string{authpkg.StatusRejected, authpkg.StatusExpired, authpkg.StatusCancelled, "UNKNOWN"}
|
||||
for _, status := range statuses {
|
||||
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||
return status, "", nil
|
||||
}
|
||||
if _, err := handlePatAuthCheck(context.WithValue(context.Background(), patSuppressBrowserOpenKey, true), &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err == nil {
|
||||
t.Fatalf("status %s returned nil", status)
|
||||
}
|
||||
}
|
||||
|
||||
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||
return authpkg.StatusApproved, "code", nil
|
||||
}
|
||||
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return wantErr }
|
||||
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { return nil, wantErr }
|
||||
skip := executor.Invocation{Params: map[string]any{"retryAfterApproval": false}}
|
||||
if got, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, skip, &apperrors.PATError{RawJSON: patRaw("f", "client", "secret")}, t.TempDir(), io.Discard); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("approved skip = %#v, %v", got, err)
|
||||
}
|
||||
|
||||
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
|
||||
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{AccessToken: "token"}, nil
|
||||
}
|
||||
patSaveTokenData = func(string, *authpkg.TokenData) error { return wantErr }
|
||||
r := &runtimeRunner{fallback: runnerCoverageFallback{result: executor.Result{Response: map[string]any{"ok": true}}}}
|
||||
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "client", "")}, t.TempDir(), io.Discard); err != nil {
|
||||
t.Fatalf("approved retry with save failure = %v", err)
|
||||
}
|
||||
patSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err != nil {
|
||||
t.Fatalf("approved retry = %v", err)
|
||||
}
|
||||
|
||||
for _, inv := range []executor.Invocation{{}, {Params: map[string]any{}}, {Params: map[string]any{"retryAfterApproval": "no"}}, {Params: map[string]any{"retryAfterApproval": true}}} {
|
||||
if shouldSkipPATRetryAfterApproval(inv) {
|
||||
t.Fatalf("unexpected skip for %#v", inv.Params)
|
||||
}
|
||||
}
|
||||
if got := enrichPATErrorForHostControl(""); got != "" {
|
||||
t.Fatalf("empty host PAT = %q", got)
|
||||
}
|
||||
if got := enrichPATErrorForHostControl("bad"); got != "bad" {
|
||||
t.Fatalf("bad host PAT = %q", got)
|
||||
}
|
||||
if got := enrichPATErrorForHostControl(`{"value":1}`); !strings.Contains(got, "value") {
|
||||
t.Fatalf("generic host PAT = %q", got)
|
||||
}
|
||||
if _, err := marshalSingleLineJSONNoHTMLEscape(map[string]any{"bad": func() {}}); err == nil {
|
||||
t.Fatal("unsupported JSON value succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func patRaw(flowID, clientID, secret string) string {
|
||||
return `{"code":"x","data":{"desc":"authorize","flowId":"` + flowID + `","uri":"https://auth.test","clientId":"` + clientID + `","clientSecret":"` + secret + `"}}`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
|
||||
oldDo := patPollHTTPDo
|
||||
oldRequest := patPollNewRequest
|
||||
oldLoad := patLoadTokenData
|
||||
oldBrowser := patBrowserOpenCommand
|
||||
t.Cleanup(func() {
|
||||
patPollHTTPDo = oldDo
|
||||
patPollNewRequest = oldRequest
|
||||
patLoadTokenData = oldLoad
|
||||
patBrowserOpenCommand = oldBrowser
|
||||
})
|
||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
|
||||
cancelled, cancelNow := context.WithCancel(context.Background())
|
||||
cancelNow()
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
|
||||
t.Fatalf("zero-interval cancelled poll = %q, %v", status, err)
|
||||
}
|
||||
expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancelExpired()
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(expired, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusExpired {
|
||||
t.Fatalf("expired-context poll = %q, %v", status, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
cancel()
|
||||
return nil, errors.New("network")
|
||||
}
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||
t.Fatalf("network poll = %q, %v", status, err)
|
||||
}
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
cancel()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{"))}, nil
|
||||
}
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||
t.Fatalf("malformed poll = %q, %v", status, err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
patPollNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) {
|
||||
cancel()
|
||||
return nil, errors.New("request")
|
||||
}
|
||||
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||
t.Fatalf("invalid request poll = %q, %v", status, err)
|
||||
}
|
||||
patPollNewRequest = oldRequest
|
||||
|
||||
var out bytes.Buffer
|
||||
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
|
||||
printPATPollDebugResponse(&out, 500, nil)
|
||||
if !strings.Contains(out.String(), "empty body") {
|
||||
t.Fatalf("empty debug response = %q", out.String())
|
||||
}
|
||||
for _, goos := range []string{"darwin", "linux", "windows", "plan9"} {
|
||||
_ = browserOpenCommand(goos, "https://example.test")
|
||||
}
|
||||
patBrowserOpenCommand = func(string, string) *exec.Cmd { return nil }
|
||||
if err := tryOpenBrowser("https://example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patBrowserOpenCommand = func(string, string) *exec.Cmd { return exec.Command("definitely-not-a-real-dws-command") }
|
||||
if err := tryOpenBrowser("https://example.test"); err == nil {
|
||||
t.Fatal("missing browser command started")
|
||||
}
|
||||
}
|
||||
@@ -650,6 +650,95 @@ func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHandlePatAuthCheckOrgPolicyDenied(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
originalClientID := authpkg.ClientID()
|
||||
originalClientSecret := authpkg.ClientSecret()
|
||||
t.Cleanup(func() {
|
||||
authpkg.SetClientID(originalClientID)
|
||||
authpkg.SetClientSecret(originalClientSecret)
|
||||
})
|
||||
|
||||
originalOpenBrowser := openBrowserFunc
|
||||
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format string
|
||||
}{
|
||||
{name: "structured", format: "json"},
|
||||
{name: "human"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
authpkg.SetClientID("existing-client-id")
|
||||
authpkg.SetClientSecret("existing-client-secret")
|
||||
opened := false
|
||||
openBrowserFunc = func(string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
retried := false
|
||||
runner := &runtimeRunner{
|
||||
globalFlags: &GlobalFlags{Format: test.format},
|
||||
fallback: &mockRunner{runFunc: func(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
retried = true
|
||||
return executor.Result{}, nil
|
||||
}},
|
||||
}
|
||||
raw := `{"success":false,"code":"PAT_ORG_POLICY_DENIED","data":{"hint":"组织策略已禁止当前工具所需的开源数据权限","scope":"contact.user.read","flowId":"terminal-flow","uri":"https://example.com/pat","clientId":"denied-client-id","clientSecret":"denied-client-secret","openBrowser":true}}`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
var out bytes.Buffer
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
CanonicalPath: "contact.get_current_user_profile",
|
||||
}, &apperrors.PATError{RawJSON: raw}, configDir, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected PATError")
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "" {
|
||||
t.Fatalf("terminal denial produced human authorization or polling output %q", got)
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("terminal denial opened a browser")
|
||||
}
|
||||
if retried {
|
||||
t.Fatal("terminal denial retried the invocation")
|
||||
}
|
||||
if got := authpkg.ClientID(); got != "existing-client-id" {
|
||||
t.Fatalf("client ID = %q, want existing process credential preserved", got)
|
||||
}
|
||||
if got := authpkg.ClientSecret(); got != "existing-client-secret" {
|
||||
t.Fatalf("client secret = %q, want existing process credential preserved", got)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") {
|
||||
t.Fatalf("data.hint = %q, want org policy guidance", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
|
||||
+24
-12
@@ -26,6 +26,18 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
pluginInstallFromGit = (*plugin.Loader).InstallFromGit
|
||||
pluginStat = os.Stat
|
||||
pluginMkdirAll = os.MkdirAll
|
||||
pluginWriteFile = os.WriteFile
|
||||
pluginAbs = filepath.Abs
|
||||
pluginRegisterDev = (*plugin.Loader).RegisterDevPlugin
|
||||
pluginListInstalled = (*plugin.Loader).ListInstalled
|
||||
pluginParseManifest = plugin.ParseManifest
|
||||
pluginBuild = plugin.BuildPlugin
|
||||
)
|
||||
|
||||
func newPluginCommand() *cobra.Command {
|
||||
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
||||
|
||||
@@ -98,7 +110,7 @@ func newPluginInstallCommand() *cobra.Command {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if gitURL != "" {
|
||||
p, err := loader.InstallFromGit(gitURL)
|
||||
p, err := pluginInstallFromGit(loader, gitURL)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||
}
|
||||
@@ -247,7 +259,7 @@ func newPluginCreateCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
dir := filepath.Join(".", name)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
if _, err := pluginStat(dir); err == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
||||
}
|
||||
|
||||
@@ -258,7 +270,7 @@ func newPluginCreateCommand() *cobra.Command {
|
||||
filepath.Join(dir, "hooks"),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
if err := pluginMkdirAll(d, 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
}
|
||||
@@ -286,7 +298,7 @@ func newPluginCreateCommand() *cobra.Command {
|
||||
}
|
||||
`, name, desc, pluginType, RawVersion(), name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||
if err := pluginWriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
||||
}
|
||||
|
||||
@@ -317,7 +329,7 @@ Use this skill when the user mentions:
|
||||
- Conversion rules
|
||||
`, name, desc, RawVersion(), name, name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
||||
if err := pluginWriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
||||
}
|
||||
|
||||
@@ -326,7 +338,7 @@ Use this skill when the user mentions:
|
||||
"hooks": []
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
||||
if err := pluginWriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
||||
}
|
||||
|
||||
@@ -377,7 +389,7 @@ to unregister.`,
|
||||
|
||||
// Register dev plugin
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
absDir, err := pluginAbs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
@@ -391,7 +403,7 @@ to unregister.`,
|
||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||
}
|
||||
|
||||
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
|
||||
if err := pluginRegisterDev(loader, m.Name, absDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
||||
}
|
||||
|
||||
@@ -582,10 +594,10 @@ func newPluginConfigUnsetCommand() *cobra.Command {
|
||||
|
||||
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
|
||||
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
|
||||
plugins := loader.ListInstalled()
|
||||
plugins := pluginListInstalled(loader)
|
||||
for _, p := range plugins {
|
||||
if p.Name == pluginName {
|
||||
m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json"))
|
||||
m, err := pluginParseManifest(filepath.Join(p.Path, "plugin.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -626,7 +638,7 @@ The build configuration is read from the "build" field in plugin.json:
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
absDir, err := pluginAbs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
@@ -646,7 +658,7 @@ The build configuration is read from the "build" field in plugin.json:
|
||||
" }", m.Name))
|
||||
}
|
||||
|
||||
if err := plugin.BuildPlugin(absDir); err != nil {
|
||||
if err := pluginBuild(absDir); err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func pluginCoverageRun(cmd *cobra.Command, args ...string) (string, error) {
|
||||
out := &bytes.Buffer{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePluginCommandRemainingCoverage(t *testing.T) {
|
||||
oldGit := pluginInstallFromGit
|
||||
oldStat := pluginStat
|
||||
oldMkdir := pluginMkdirAll
|
||||
oldWrite := pluginWriteFile
|
||||
oldAbs := pluginAbs
|
||||
oldRegister := pluginRegisterDev
|
||||
oldBuild := pluginBuild
|
||||
oldList, oldParse := pluginListInstalled, pluginParseManifest
|
||||
t.Cleanup(func() {
|
||||
pluginInstallFromGit = oldGit
|
||||
pluginStat = oldStat
|
||||
pluginMkdirAll = oldMkdir
|
||||
pluginWriteFile = oldWrite
|
||||
pluginAbs = oldAbs
|
||||
pluginRegisterDev = oldRegister
|
||||
pluginBuild = oldBuild
|
||||
pluginListInstalled, pluginParseManifest = oldList, oldParse
|
||||
})
|
||||
home := t.TempDir()
|
||||
work := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
oldWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
fail := errors.New("failure")
|
||||
pluginListInstalled = func(*plugin.Loader) []plugin.PluginInfo {
|
||||
return []plugin.PluginInfo{{Name: "broken", Path: "missing"}}
|
||||
}
|
||||
pluginParseManifest = func(string) (*plugin.Manifest, error) { return nil, fail }
|
||||
if got := loadDeclaredUserConfig(plugin.NewLoader(RawVersion()), "broken"); got != nil {
|
||||
t.Fatalf("broken declared config = %#v", got)
|
||||
}
|
||||
pluginListInstalled, pluginParseManifest = oldList, oldParse
|
||||
|
||||
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) { return nil, fail }
|
||||
if _, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err == nil {
|
||||
t.Fatal("git install failure should propagate")
|
||||
}
|
||||
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) {
|
||||
return &plugin.Plugin{Manifest: plugin.Manifest{Name: "git-plugin", Version: "1.0.0"}}, nil
|
||||
}
|
||||
if out, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err != nil || !strings.Contains(out, "git-plugin") {
|
||||
t.Fatalf("git install = %q, %v", out, err)
|
||||
}
|
||||
if _, err := pluginCoverageRun(newPluginDisableCommand(), "missing"); err == nil {
|
||||
t.Fatal("disable missing plugin should fail")
|
||||
}
|
||||
|
||||
invalidDir := filepath.Join(work, "invalid")
|
||||
if err := os.MkdirAll(invalidDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(invalidDir, "plugin.json"), []byte(`{"name":"ok-name","version":"1.0.0","type":"invalid"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pluginCoverageRun(newPluginValidateCommand(), invalidDir); err == nil {
|
||||
t.Fatal("invalid manifest validation should fail")
|
||||
}
|
||||
|
||||
pluginStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
pluginMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if _, err := pluginCoverageRun(newPluginCreateCommand(), "mkdir-plugin"); err == nil {
|
||||
t.Fatal("scaffold mkdir failure should propagate")
|
||||
}
|
||||
pluginMkdirAll = oldMkdir
|
||||
for _, target := range []string{"plugin.json", "SKILL.md", "hooks.json"} {
|
||||
name := "write-" + strings.ToLower(strings.TrimSuffix(target, filepath.Ext(target)))
|
||||
pluginWriteFile = func(path string, data []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, target) {
|
||||
return fail
|
||||
}
|
||||
return oldWrite(path, data, mode)
|
||||
}
|
||||
if _, err := pluginCoverageRun(newPluginCreateCommand(), name); err == nil {
|
||||
t.Fatalf("scaffold %s failure should propagate", target)
|
||||
}
|
||||
}
|
||||
pluginWriteFile = oldWrite
|
||||
|
||||
pluginAbs = func(string) (string, error) { return "", fail }
|
||||
if _, err := pluginCoverageRun(newPluginDevCommand(), "dir"); err == nil {
|
||||
t.Fatal("dev absolute-path failure should propagate")
|
||||
}
|
||||
pluginAbs = oldAbs
|
||||
if _, err := pluginCoverageRun(newPluginDevCommand(), invalidDir); err == nil {
|
||||
t.Fatal("dev manifest validation should fail")
|
||||
}
|
||||
validDir := filepath.Join(work, "valid")
|
||||
if err := os.MkdirAll(validDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{
|
||||
"name":"valid-plugin","version":"1.0.0","type":"user",
|
||||
"userConfig":{
|
||||
"API_KEY":{"description":"secret key","sensitive":true},
|
||||
"PLAIN":{"description":"plain value","default":"default"},
|
||||
"UNSET":{"description":"required value"}
|
||||
},
|
||||
"build":{"command":"true","output":"bin/server"}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pluginRegisterDev = func(*plugin.Loader, string, string) error { return fail }
|
||||
if _, err := pluginCoverageRun(newPluginDevCommand(), validDir); err == nil {
|
||||
t.Fatal("dev registration failure should propagate")
|
||||
}
|
||||
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
installed, err := loader.InstallFromDir(validDir)
|
||||
if err != nil {
|
||||
// The declared build output is intentionally absent; install a no-build copy.
|
||||
noBuild := strings.Replace(manifest, ",\n \"build\":{\"command\":\"true\",\"output\":\"bin/server\"}", "", 1)
|
||||
if writeErr := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(noBuild), 0o600); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
installed, err = loader.InstallFromDir(validDir)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loader.SetPluginConfig("valid-plugin", "API_KEY", "abcdefghijk")
|
||||
loader.SetPluginConfig("valid-plugin", "PLAIN", "value")
|
||||
for _, args := range [][]string{{"valid-plugin"}, {"valid-plugin", "--json"}} {
|
||||
out, runErr := pluginCoverageRun(newPluginConfigListCommand(), args...)
|
||||
if runErr != nil || !strings.Contains(out, "UNSET") || !strings.Contains(out, "abcd") {
|
||||
t.Fatalf("config list %#v = %q, %v", args, out, runErr)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installed.Root, "plugin.json"), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadDeclaredUserConfig(loader, "valid-plugin"); got != nil {
|
||||
t.Fatalf("corrupt declared config = %#v", got)
|
||||
}
|
||||
|
||||
pluginAbs = func(string) (string, error) { return "", fail }
|
||||
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
|
||||
t.Fatal("build absolute-path failure should propagate")
|
||||
}
|
||||
pluginAbs = oldAbs
|
||||
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pluginBuild = func(string) error { return fail }
|
||||
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
|
||||
t.Fatal("plugin build failure should propagate")
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func newProfileListCommand() *cobra.Command {
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
cfg, err := profileLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
@@ -139,6 +139,12 @@ func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
||||
var (
|
||||
profileSwitchSelector = selectProfileSwitchProfile
|
||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||
profileSwitchTUIRunner = runProfileSwitchTUI
|
||||
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||
profileLoadProfiles = authpkg.LoadProfiles
|
||||
profileUsePrevious = authpkg.UsePreviousProfile
|
||||
profileSetCurrent = authpkg.SetCurrentProfile
|
||||
profileRunTeaProgram = (*tea.Program).Run
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -218,9 +224,9 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(selector) == "-" {
|
||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
||||
profile, err = profileUsePrevious(configDir)
|
||||
} else {
|
||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
||||
profile, err = profileSetCurrent(configDir, selector)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
@@ -229,7 +235,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
|
||||
clearCompatCache()
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
||||
cfg, loadErr := profileLoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||
}
|
||||
@@ -243,10 +249,10 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
||||
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 {
|
||||
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
cfg, err := profileLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
@@ -260,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
}
|
||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
||||
return profileSwitchTUIRunner(cmd, cfg, choice)
|
||||
}
|
||||
|
||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||
@@ -272,7 +278,7 @@ func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, select
|
||||
tea.WithOutput(cmd.ErrOrStderr()),
|
||||
tea.WithContext(cmd.Context()),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
finalModel, err := profileRunTeaProgram(program)
|
||||
if err != nil {
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
|
||||
@@ -110,6 +110,7 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
CloseFileLogger()
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||||
}
|
||||
@@ -136,6 +137,7 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
CloseFileLogger()
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||||
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
|
||||
}
|
||||
@@ -202,6 +204,7 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
CloseFileLogger()
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
@@ -218,6 +221,7 @@ func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
CloseFileLogger()
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageProfileRemainingCoverage(t *testing.T) {
|
||||
oldMigrate := profileEnsureProfilesMigration
|
||||
oldLoad := profileLoadProfiles
|
||||
oldPrevious := profileUsePrevious
|
||||
oldCurrent := profileSetCurrent
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
oldTUI := profileSwitchTUIRunner
|
||||
oldProgram := profileRunTeaProgram
|
||||
t.Cleanup(func() {
|
||||
profileEnsureProfilesMigration = oldMigrate
|
||||
profileLoadProfiles = oldLoad
|
||||
profileUsePrevious = oldPrevious
|
||||
profileSetCurrent = oldCurrent
|
||||
profileSwitchInteractiveTerminal = oldInteractive
|
||||
profileSwitchTUIRunner = oldTUI
|
||||
profileRunTeaProgram = oldProgram
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
|
||||
list := newProfileListCommand()
|
||||
_, _, _ = authCoverageRoot(list, "table", false)
|
||||
profileEnsureProfilesMigration = func(string) error { return fail }
|
||||
if err := list.RunE(list, nil); err == nil {
|
||||
t.Fatal("profile-list migration should fail")
|
||||
}
|
||||
profileEnsureProfilesMigration = func(string) error { return nil }
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||
if err := list.RunE(list, nil); err == nil {
|
||||
t.Fatal("profile-list load should fail")
|
||||
}
|
||||
|
||||
selectorCmd := &cobra.Command{}
|
||||
addProfileSwitchSelectorFlags(selectorCmd)
|
||||
_ = selectorCmd.Flags().Set("name", " ")
|
||||
if _, err := profileSwitchSelectorFromCommand(selectorCmd, nil); err == nil {
|
||||
t.Fatal("blank profile selector should fail")
|
||||
}
|
||||
|
||||
profileSetCurrent = func(string, string) (*authpkg.Profile, error) {
|
||||
return &authpkg.Profile{CorpID: "ding", CorpName: "Corp"}, nil
|
||||
}
|
||||
profileUsePrevious = func(string) (*authpkg.Profile, error) { return nil, fail }
|
||||
cmd := &cobra.Command{}
|
||||
_, _, _ = authCoverageRoot(cmd, "table", false)
|
||||
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err == nil {
|
||||
t.Fatal("previous-profile error should fail")
|
||||
}
|
||||
profileUsePrevious = func(string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "previous"}, nil }
|
||||
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonCmd := &cobra.Command{}
|
||||
_, _, _ = authCoverageRoot(jsonCmd, "json", false)
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
|
||||
t.Fatal("JSON profile load should fail")
|
||||
}
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{CurrentProfile: "ding"}, nil
|
||||
}
|
||||
jsonCmd.SetOut(&appFailWriter{err: fail})
|
||||
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
|
||||
t.Fatal("JSON profile write should fail")
|
||||
}
|
||||
|
||||
profileSwitchInteractiveTerminal = func() bool { return true }
|
||||
profileEnsureProfilesMigration = func(string) error { return fail }
|
||||
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||
t.Fatal("selector migration should fail")
|
||||
}
|
||||
profileEnsureProfilesMigration = func(string) error { return nil }
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||
t.Fatal("selector load should fail")
|
||||
}
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||
t.Fatal("empty selector profiles should fail")
|
||||
}
|
||||
choices := []string{}
|
||||
profileSwitchTUIRunner = func(_ *cobra.Command, _ *authpkg.ProfilesConfig, choice string) (string, error) {
|
||||
choices = append(choices, choice)
|
||||
return choice, nil
|
||||
}
|
||||
for _, cfg := range []*authpkg.ProfilesConfig{
|
||||
{CurrentProfile: "current", PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||
{PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||
{Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||
} {
|
||||
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" {
|
||||
t.Fatalf("profile choices = %#v", choices)
|
||||
}
|
||||
|
||||
tuiCmd := &cobra.Command{}
|
||||
tuiCmd.SetContext(context.Background())
|
||||
tuiCmd.SetIn(io.NopCloser(&emptyReader{}))
|
||||
tuiCmd.SetErr(io.Discard)
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding"}}}
|
||||
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, tea.ErrInterrupted }
|
||||
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||
t.Fatal("interrupted TUI should fail")
|
||||
}
|
||||
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, fail }
|
||||
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||
t.Fatal("failed TUI should fail")
|
||||
}
|
||||
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return structModel{}, nil }
|
||||
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||
t.Fatal("wrong TUI model should fail")
|
||||
}
|
||||
for _, model := range []profileSwitchTUIModel{{aborted: true}, {submitted: false}} {
|
||||
model := model
|
||||
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return model, nil }
|
||||
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||
t.Fatal("aborted TUI should fail")
|
||||
}
|
||||
}
|
||||
final := newProfileSwitchTUIModel(cfg, "ding")
|
||||
final.submitted = true
|
||||
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return final, nil }
|
||||
if got, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err != nil || got != "ding" {
|
||||
t.Fatalf("submitted TUI = %q, %v", got, err)
|
||||
}
|
||||
|
||||
profiles := make([]authpkg.Profile, 8)
|
||||
model := profileSwitchTUIModel{profiles: profiles, selected: 7, offset: 99}
|
||||
model.ensureSelectedVisible()
|
||||
if model.offset != 3 {
|
||||
t.Fatalf("clamped offset = %d", model.offset)
|
||||
}
|
||||
model.offset = -2
|
||||
model.selected = 0
|
||||
model.ensureSelectedVisible()
|
||||
if model.offset != 0 {
|
||||
t.Fatalf("negative offset = %d", model.offset)
|
||||
}
|
||||
}
|
||||
|
||||
type emptyReader struct{}
|
||||
|
||||
func (*emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
|
||||
type structModel struct{}
|
||||
|
||||
func (structModel) Init() tea.Cmd { return nil }
|
||||
func (structModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return structModel{}, nil }
|
||||
func (structModel) View() string { return "" }
|
||||
@@ -18,6 +18,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
recoverySavePlan = (*recovery.Store).SavePlan
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
|
||||
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
@@ -60,7 +65,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
if err := store.SavePlan(last.EventID, plan); err != nil {
|
||||
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
executor := recovery.NewExecutor(planner, runtime)
|
||||
bundle := executor.Execute(cmd.Context(), *last)
|
||||
if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil {
|
||||
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(cmdArgs)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||
t.Cleanup(func() {
|
||||
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
store := recovery.NewStore(configDir)
|
||||
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("injected plan save failure succeeded")
|
||||
}
|
||||
recoverySavePlan = oldSavePlan
|
||||
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||
return errors.New("save analysis")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("injected analysis save failure succeeded")
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(context.Background(), nil, nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"finalize"},
|
||||
{"finalize", "--event-id", last.EventID},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||
} {
|
||||
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||
t.Fatalf("recovery finalize %#v should fail", args)
|
||||
}
|
||||
}
|
||||
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||
t.Fatal("conflicting snapshot selectors should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||
t.Fatal("missing event snapshot should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||
t.Fatal("empty snapshot selector should fail")
|
||||
}
|
||||
missingStore := recovery.NewStore(t.TempDir())
|
||||
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||
t.Fatal("missing latest snapshot should fail")
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||
if err := os.Remove(eventsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("recovery plan save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("recovery analysis save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||
t.Fatal("recovery finalization save should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("invalid attempts should fail")
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
fail := errors.New("catalog")
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail),
|
||||
transport: transport.NewClient(nil),
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
t.Fatalf("search error = %#v, %v", got, err)
|
||||
}
|
||||
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||
t.Fatalf("empty doc search items = %#v", got)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"data": map[string]any{}},
|
||||
{"result": map[string]any{}},
|
||||
} {
|
||||
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||
t.Fatalf("empty nested doc search items = %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventStopPreviewConfirmationAndStdinCoverage(t *testing.T) {
|
||||
originalNormalize := eventNormalizeAs
|
||||
t.Cleanup(func() { eventNormalizeAs = originalNormalize })
|
||||
eventNormalizeAs = func(string) (string, error) { return "app", nil }
|
||||
newStopRoot := func(dryRun, yes bool) (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", dryRun, "")
|
||||
root.PersistentFlags().Bool("yes", yes, "")
|
||||
root.AddCommand(newEventStopCommand())
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
return root, &output
|
||||
}
|
||||
root, output := newStopRoot(true, false)
|
||||
root.SetArgs([]string{"stop"})
|
||||
if err := root.Execute(); err != nil || !strings.Contains(output.String(), "dry_run") {
|
||||
t.Fatalf("event stop dry-run = %v, %q", err, output.String())
|
||||
}
|
||||
root, _ = newStopRoot(false, false)
|
||||
root.SetArgs([]string{"stop"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("event stop confirmation error = %v", err)
|
||||
}
|
||||
|
||||
originalStdin := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = originalStdin })
|
||||
closed, err := os.CreateTemp(t.TempDir(), "closed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closed.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdin = closed
|
||||
if shouldWatchStdinEOF(0, 0) {
|
||||
t.Fatal("shouldWatchStdinEOF(closed stdin) = true")
|
||||
}
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer read.Close()
|
||||
defer write.Close()
|
||||
os.Stdin = read
|
||||
if !shouldWatchStdinEOF(0, 0) {
|
||||
t.Fatal("shouldWatchStdinEOF(pipe) = false")
|
||||
}
|
||||
var cfg consume.Config
|
||||
applyEventConsumeStdin(nil, 0, 0, strings.NewReader("ignored"))
|
||||
applyEventConsumeStdin(&cfg, 1, 0, strings.NewReader("bounded"))
|
||||
if cfg.Stdin != nil {
|
||||
t.Fatal("bounded consume received stdin watcher")
|
||||
}
|
||||
wantReader := strings.NewReader("pipe")
|
||||
applyEventConsumeStdin(&cfg, 0, 0, wantReader)
|
||||
if cfg.Stdin != wantReader {
|
||||
t.Fatal("unbounded pipe consume did not receive stdin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerPluginStdioAndDryRunErrorCoverage(t *testing.T) {
|
||||
if _, err := (*runtimeRunner)(nil).Run(context.Background(), executor.Invocation{}); err == nil {
|
||||
t.Fatal("nil runtime runner error = nil")
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "codex")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv(envDingtalkAgent, "agent")
|
||||
t.Setenv(envDingtalkTraceID, "trace")
|
||||
t.Setenv(envDingtalkMessageID, "message")
|
||||
headers := resolveIdentityHeaders()
|
||||
for _, key := range []string{"x-dws-agent-instance-id", "x-dingtalk-agent", "x-dingtalk-trace-id", "x-dingtalk-message-id"} {
|
||||
if headers[key] == "" {
|
||||
t.Fatalf("identity header %q missing: %#v", key, headers)
|
||||
}
|
||||
}
|
||||
|
||||
stdioMu.Lock()
|
||||
previous := stdioClients
|
||||
stdioClients = map[string]*transport.StdioClient{"plugin/server": transport.NewStdioClient("missing", nil, nil)}
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = previous
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
if _, ok := LookupStdioClient("server"); !ok {
|
||||
t.Fatal("LookupStdioClient(server suffix) failed")
|
||||
}
|
||||
if StopStdioClient("missing") {
|
||||
t.Fatal("StopStdioClient(missing) = true")
|
||||
}
|
||||
|
||||
registerPluginHTTPServer(mcptypes.ServerDescriptor{Key: "coverage", Endpoint: "https://example.test", AuthHeaders: map[string]string{"Authorization": "Bearer token"}})
|
||||
|
||||
originalDryRun := toolCallerDryRun
|
||||
t.Cleanup(func() { toolCallerDryRun = originalDryRun })
|
||||
toolCallerDryRun = func(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("dry-run")
|
||||
}
|
||||
adapter := newToolCallerAdapter(nil, &GlobalFlags{DryRun: true})
|
||||
if _, err := adapter.CallTool(context.Background(), "product", "tool", nil); err == nil {
|
||||
t.Fatal("tool caller dry-run error = nil")
|
||||
}
|
||||
}
|
||||
+74
-31
@@ -24,6 +24,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -49,6 +51,30 @@ type outputFileContextKey struct{}
|
||||
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
var (
|
||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||
rootRunPreParse = pipeline.RunPreParse
|
||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
rootCreateFile = os.Create
|
||||
rootCloseFile = (*os.File).Close
|
||||
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
|
||||
rootPluginStdioClients = (*plugin.Plugin).StdioClients
|
||||
rootRegisterPluginHTTPServer = registerPluginHTTPServer
|
||||
rootRegisterStdioManifest = registerStdioServerFromManifest
|
||||
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
|
||||
rootPluginSyncSkills = plugin.SyncSkills
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
defer func() {
|
||||
@@ -58,13 +84,13 @@ func Execute() (exitCode int) {
|
||||
}
|
||||
}()
|
||||
|
||||
restoreArgs := normalizeProcessProfileArgs()
|
||||
restoreArgs := rootNormalizeProcessProfileArgs()
|
||||
defer restoreArgs()
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||
rootStopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||
// including command errors where Cobra skips PersistentPostRunE.
|
||||
timing.PrintIfEnabled()
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
@@ -77,17 +103,17 @@ func Execute() (exitCode int) {
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
|
||||
initStart := time.Now()
|
||||
recovery.ResetRuntimeState()
|
||||
rootResetRecoveryState()
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
// This corrects model-generated errors like --userId → --user-id
|
||||
// and --limit100 → --limit 100.
|
||||
pipeline.RunPreParse(root, engine)
|
||||
rootRunPreParse(root, engine)
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
executed, err := rootExecuteCommand(root)
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
@@ -99,7 +125,7 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
|
||||
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
@@ -254,9 +280,6 @@ func commandRequestsJSONErrors(cmd *cobra.Command) bool {
|
||||
cmd.InheritedFlags(),
|
||||
cmd.PersistentFlags(),
|
||||
} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
if flag := flags.Lookup("format"); flag != nil {
|
||||
if value, err := flags.GetString("format"); err == nil && strings.EqualFold(strings.TrimSpace(value), "json") {
|
||||
return true
|
||||
@@ -346,7 +369,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
// Wrap the caller so every MCP tool call's shape is recorded to the local
|
||||
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
|
||||
// `dws shortcut stats` and future high-frequency shortcut distillation.
|
||||
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
@@ -364,6 +390,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
usage.NewShortcutCommand(),
|
||||
schemaCmd,
|
||||
mcpCmd,
|
||||
}
|
||||
@@ -374,7 +401,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
|
||||
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
|
||||
// be appended on top of the static endpoint registry.
|
||||
pluginCmds := loadPlugins(engine, runner)
|
||||
pluginCmds := rootLoadPlugins(engine, runner)
|
||||
if len(pluginCmds) > 0 {
|
||||
addPluginCommandsSafe(root, pluginCmds)
|
||||
}
|
||||
@@ -686,10 +713,10 @@ func configureOutputSink(cmd *cobra.Command) error {
|
||||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||
}
|
||||
file, err := os.Create(outputPath)
|
||||
file, err := rootCreateFile(outputPath)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||||
}
|
||||
@@ -703,7 +730,7 @@ func closeOutputSink(cmd *cobra.Command) error {
|
||||
if !ok || file == nil {
|
||||
return nil
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
if err := rootCloseFile(file); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||||
}
|
||||
return nil
|
||||
@@ -722,7 +749,10 @@ func validateOptionalPath(flagName, path string) error {
|
||||
|
||||
// fileLogger holds the package-level file logger for diagnostics.
|
||||
// It is initialized by configureLogLevel and closed by CloseFileLogger.
|
||||
var fileLogger *logging.FileLogger
|
||||
var (
|
||||
fileLoggerMu sync.Mutex
|
||||
fileLogger *logging.FileLogger
|
||||
)
|
||||
|
||||
// configureLogLevel sets the global slog level based on --debug and --verbose flags
|
||||
// and initializes the file logger for diagnostics.
|
||||
@@ -744,14 +774,24 @@ func configureLogLevel(flags *GlobalFlags) {
|
||||
|
||||
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
|
||||
// regardless of stderr level. All slog calls are captured for diagnostics.
|
||||
fileLogger = logging.Setup(defaultConfigDir())
|
||||
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
logger := logging.Setup(defaultConfigDir())
|
||||
fileHandler := slog.NewJSONHandler(logger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
defaultLogger := slog.New(logging.NewMultiHandler(stderrHandler, fileHandler))
|
||||
|
||||
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
previous := fileLogger
|
||||
fileLogger = logger
|
||||
slog.SetDefault(defaultLogger)
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
|
||||
func FileLoggerInstance() *slog.Logger {
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
if fileLogger == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -760,8 +800,11 @@ func FileLoggerInstance() *slog.Logger {
|
||||
|
||||
// CloseFileLogger flushes and closes the file logger.
|
||||
func CloseFileLogger() {
|
||||
fileLoggerMu.Lock()
|
||||
defer fileLoggerMu.Unlock()
|
||||
if fileLogger != nil {
|
||||
fileLogger.Close()
|
||||
_ = fileLogger.Close()
|
||||
fileLogger = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,10 +818,10 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
// variables so that expandPluginVars can resolve ${KEY} references
|
||||
// in plugin.json headers, endpoints, etc. User-set env vars take
|
||||
// precedence (InjectPluginConfigEnv skips already-set keys).
|
||||
pluginLoader.InjectPluginConfigEnv()
|
||||
rootPluginInjectConfigEnv(pluginLoader)
|
||||
|
||||
// Load TokenData once; reused for stdio injection below.
|
||||
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
|
||||
tokenData, _ := rootAuthLoadTokenData(defaultConfigDir())
|
||||
var userCtx *plugin.UserContext
|
||||
if tokenData != nil {
|
||||
// Inject user context if either UserID or CorpID is present.
|
||||
@@ -791,32 +834,32 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
}
|
||||
|
||||
// 1. Load user plugins (per settings.json)
|
||||
userPlugins := pluginLoader.LoadUser()
|
||||
userPlugins := rootPluginLoadUser(pluginLoader)
|
||||
|
||||
// 2. Load dev plugins (registered via `dws plugin dev`)
|
||||
devPlugins := pluginLoader.LoadDev()
|
||||
devPlugins := rootPluginLoadDev(pluginLoader)
|
||||
|
||||
allPlugins := append(userPlugins, devPlugins...)
|
||||
|
||||
// 3. Register HTTP descriptors and authentication from the manifest.
|
||||
for _, p := range allPlugins {
|
||||
for _, srv := range p.ToServerDescriptors() {
|
||||
registerPluginHTTPServer(srv)
|
||||
for _, srv := range rootPluginDescriptors(p) {
|
||||
rootRegisterPluginHTTPServer(srv)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
||||
// started and initialized only when a command is actually executed.
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range p.StdioClients(userCtx) {
|
||||
registerStdioServerFromManifest(p, sc)
|
||||
for _, sc := range rootPluginStdioClients(p, userCtx) {
|
||||
rootRegisterStdioManifest(p, sc)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Register plugin hooks into pipeline engine
|
||||
if engine != nil {
|
||||
for _, p := range allPlugins {
|
||||
hooksCfg, err := p.LoadHooks()
|
||||
hooksCfg, err := rootPluginLoadHooks(p)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: failed to load hooks",
|
||||
"plugin", p.Manifest.Name, "error", err)
|
||||
@@ -832,7 +875,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
}
|
||||
|
||||
// 7. Sync plugin skills to agent directories
|
||||
plugin.SyncSkills(allPlugins)
|
||||
rootPluginSyncSkills(allPlugins)
|
||||
|
||||
if len(allPlugins) > 0 {
|
||||
slog.Debug("plugins loaded",
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldLatest := rootLatestRecoveryCapture
|
||||
oldReset := rootResetRecoveryState
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootLatestRecoveryCapture = oldLatest
|
||||
rootResetRecoveryState = oldReset
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
}
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing.T) {
|
||||
oldLoadPlugins := rootLoadPlugins
|
||||
oldEdition := edition.Get()
|
||||
oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
|
||||
t.Cleanup(func() {
|
||||
rootLoadPlugins = oldLoadPlugins
|
||||
edition.Override(oldEdition)
|
||||
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
|
||||
})
|
||||
|
||||
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
|
||||
}
|
||||
preRunCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(*cobra.Command, []string) error { preRunCalled = true; return nil },
|
||||
RegisterExtraCommands: func(root *cobra.Command, _ edition.ToolCaller) {
|
||||
root.AddCommand(&cobra.Command{Use: "extra", Run: func(*cobra.Command, []string) {}})
|
||||
},
|
||||
})
|
||||
root := NewRootCommandWithEngine(context.Background(), pipeline.NewEngine())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version", "--client-id", "client", "--client-secret", "secret", "--debug"})
|
||||
if err := root.Execute(); err != nil || !preRunCalled {
|
||||
t.Fatalf("root version execution = %v preRun=%v", err, preRunCalled)
|
||||
}
|
||||
|
||||
root = NewRootCommandWithEngine(context.Background(), nil)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(nil)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("root help execution = %v", err)
|
||||
}
|
||||
|
||||
standalone := newVersionCommand()
|
||||
standalone.Flags().String("format", "", "")
|
||||
standalone.SetOut(io.Discard)
|
||||
standalone.SetArgs([]string{"--format", "json"})
|
||||
version, buildTime, gitCommit = "1.2.3", "today", "commit"
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := standalone.Execute(); err != nil {
|
||||
t.Fatalf("JSON version = %v", err)
|
||||
}
|
||||
|
||||
root = NewRootCommandWithEngine(context.Background(), nil)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version", "--output", "bad\x00path"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("unsafe output path succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
parent.PersistentFlags().String("format", "json", "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
parent.AddCommand(child)
|
||||
if !wantsJSONErrors(child) {
|
||||
t.Fatal("root JSON format was not inherited")
|
||||
}
|
||||
localRoot := &cobra.Command{Use: "root"}
|
||||
localRoot.Flags().String("format", "json", "")
|
||||
localChild := &cobra.Command{Use: "child"}
|
||||
localRoot.AddCommand(localChild)
|
||||
if !wantsJSONErrors(localChild) {
|
||||
t.Fatal("root-local JSON format was not recognized")
|
||||
}
|
||||
falseJSON := &cobra.Command{Use: "false-json"}
|
||||
falseJSON.Flags().Bool("json", true, "")
|
||||
_ = falseJSON.Flags().Set("json", "false")
|
||||
if commandRequestsJSONErrors(falseJSON) {
|
||||
t.Fatal("explicit false JSON flag requested JSON")
|
||||
}
|
||||
brokenJSON := &cobra.Command{Use: "broken"}
|
||||
brokenJSON.Flags().String("json", "not-bool", "")
|
||||
_ = brokenJSON.Flags().Set("json", "value")
|
||||
if !commandRequestsJSONErrors(brokenJSON) {
|
||||
t.Fatal("changed non-bool json flag was not treated as JSON")
|
||||
}
|
||||
|
||||
pluginRoot := &cobra.Command{Use: "root"}
|
||||
pluginRoot.AddCommand(&cobra.Command{Use: "market"})
|
||||
addPluginCommandsSafe(pluginRoot, []*cobra.Command{
|
||||
{Use: "auth"},
|
||||
{Use: "duplicate"},
|
||||
{Use: "duplicate"},
|
||||
{Use: "market"},
|
||||
})
|
||||
|
||||
oldMkdir := rootMkdirAll
|
||||
oldCreate := rootCreateFile
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() {
|
||||
rootMkdirAll = oldMkdir
|
||||
rootCreateFile = oldCreate
|
||||
rootCloseFile = oldClose
|
||||
})
|
||||
wantErr := errors.New("filesystem")
|
||||
newOutputCommand := func(path string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().String("output", path, "")
|
||||
cmd := &cobra.Command{Use: "output"}
|
||||
root.AddCommand(cmd)
|
||||
cmd.SetContext(context.Background())
|
||||
return cmd
|
||||
}
|
||||
successPath := filepath.Join("success", "out")
|
||||
successCmd := newOutputCommand(successPath)
|
||||
if err := configureOutputSink(successCmd); err != nil {
|
||||
t.Fatalf("output sink success = %v", err)
|
||||
}
|
||||
if err := closeOutputSink(successCmd); err != nil {
|
||||
t.Fatalf("output sink close = %v", err)
|
||||
}
|
||||
badTypeRoot := &cobra.Command{Use: "root"}
|
||||
badTypeRoot.PersistentFlags().Bool("output", false, "")
|
||||
badTypeChild := &cobra.Command{Use: "child"}
|
||||
badTypeRoot.AddCommand(badTypeChild)
|
||||
if err := configureOutputSink(badTypeChild); err == nil {
|
||||
t.Fatal("non-string output flag succeeded")
|
||||
}
|
||||
rootMkdirAll = func(string, os.FileMode) error { return wantErr }
|
||||
if err := configureOutputSink(newOutputCommand(filepath.Join("mkdir-failure", "out"))); err == nil {
|
||||
t.Fatal("mkdir failure succeeded")
|
||||
}
|
||||
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
||||
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
||||
t.Fatal("create failure succeeded")
|
||||
}
|
||||
rootCreateFile = oldCreate
|
||||
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "close"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
rootCloseFile = func(*os.File) error { return wantErr }
|
||||
if err := closeOutputSink(cmd); err == nil {
|
||||
t.Fatal("close failure succeeded")
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("cleanup close-failure file = %v", err)
|
||||
}
|
||||
rootCloseFile = oldClose
|
||||
file, err = os.CreateTemp(t.TempDir(), "close-success")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
if err := closeOutputSink(cmd); err != nil {
|
||||
t.Fatalf("close success = %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
for _, flags := range []*GlobalFlags{nil, {Debug: true}, {Verbose: true}, {}} {
|
||||
configureLogLevel(flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
oldInject := rootPluginInjectConfigEnv
|
||||
oldUser := rootPluginLoadUser
|
||||
oldDev := rootPluginLoadDev
|
||||
oldDescriptors := rootPluginDescriptors
|
||||
oldStdioClients := rootPluginStdioClients
|
||||
oldHTTP := rootRegisterPluginHTTPServer
|
||||
oldStdio := rootRegisterStdioManifest
|
||||
oldHooks := rootPluginLoadHooks
|
||||
oldSync := rootPluginSyncSkills
|
||||
oldToken := rootAuthLoadTokenData
|
||||
t.Cleanup(func() {
|
||||
rootPluginInjectConfigEnv = oldInject
|
||||
rootPluginLoadUser = oldUser
|
||||
rootPluginLoadDev = oldDev
|
||||
rootPluginDescriptors = oldDescriptors
|
||||
rootPluginStdioClients = oldStdioClients
|
||||
rootRegisterPluginHTTPServer = oldHTTP
|
||||
rootRegisterStdioManifest = oldStdio
|
||||
rootPluginLoadHooks = oldHooks
|
||||
rootPluginSyncSkills = oldSync
|
||||
rootAuthLoadTokenData = oldToken
|
||||
})
|
||||
|
||||
p1 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "one"}}
|
||||
p2 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "two"}}
|
||||
p3 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "three"}}
|
||||
rootPluginInjectConfigEnv = func(*plugin.Loader) {}
|
||||
rootPluginLoadUser = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p1, p2} }
|
||||
rootPluginLoadDev = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p3} }
|
||||
rootAuthLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{UserID: "user", CorpID: "corp"}, nil
|
||||
}
|
||||
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
|
||||
if p == p1 {
|
||||
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
|
||||
}
|
||||
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
|
||||
}
|
||||
client := transport.NewStdioClient("ignored", nil, nil)
|
||||
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
|
||||
if p == p1 && uc != nil && uc.UserID == "user" {
|
||||
return []plugin.StdioServerClient{{Key: "local", Client: client}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
httpCount := 0
|
||||
stdioCount := 0
|
||||
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
|
||||
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
stdioCount++
|
||||
return mcptypes.ServerDescriptor{}
|
||||
}
|
||||
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
|
||||
switch p {
|
||||
case p1:
|
||||
return nil, errors.New("hooks")
|
||||
case p2:
|
||||
return nil, nil
|
||||
default:
|
||||
return &plugin.HooksConfig{Hooks: []plugin.HookEntry{{Phase: "pre-request", Command: "true"}}}, nil
|
||||
}
|
||||
}
|
||||
synced := false
|
||||
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
|
||||
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
|
||||
t.Fatalf("loaded plugin commands = %#v", got)
|
||||
}
|
||||
if httpCount != 3 || stdioCount != 1 || !synced {
|
||||
t.Fatalf("registered http=%d stdio=%d synced=%v", httpCount, stdioCount, synced)
|
||||
}
|
||||
}
|
||||
@@ -183,9 +183,6 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
}
|
||||
|
||||
allowed := resolveVisibleProducts()
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commands := make([]*cobra.Command, 0)
|
||||
for _, cmd := range root.Commands() {
|
||||
|
||||
@@ -47,6 +47,25 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"calendar", "event", "create", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("calendar event create --help: %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "--rooms string") {
|
||||
t.Fatalf("calendar event create help missing string metavar for --rooms:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--rooms room search") {
|
||||
t.Fatalf("calendar event create help treated description text as --rooms metavar:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
|
||||
+48
-28
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,6 +41,9 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
runnerHandlePatAuthCheck = handlePatAuthCheck
|
||||
runnerRetryWithPatAuthRetry = retryWithPatAuthRetry
|
||||
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
@@ -143,7 +147,6 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
auditSink: setupAuditSink(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +161,19 @@ type runtimeRunner struct {
|
||||
auditSink audit.Sink
|
||||
}
|
||||
|
||||
var (
|
||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||
runnerResolveProfile = authpkg.ResolveProfile
|
||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
runnerStdioCallTool = (*transport.StdioClient).CallTool
|
||||
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
|
||||
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
|
||||
runnerCaptureRuntimeFailure = captureRuntimeFailure
|
||||
)
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Global dry-run is an execution barrier, not merely a transport option.
|
||||
// Return a deterministic local preview before profile resolution, catalog
|
||||
@@ -177,7 +193,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
@@ -207,7 +223,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
if prefetchToken {
|
||||
go getCachedRuntimeToken(ctx)
|
||||
go runnerGetCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
@@ -278,7 +294,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
if p, err := runnerResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
@@ -290,7 +306,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||
}
|
||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
||||
profile, err := runnerResolveProfile(configDir, selector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -306,9 +322,6 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
||||
Profile: *profile,
|
||||
})
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return selections, true, nil
|
||||
}
|
||||
|
||||
@@ -456,6 +469,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
}
|
||||
|
||||
// Constructing the Cobra tree is also used for help, schema, and command
|
||||
// discovery. Open the process-wide audit writer only when a real invocation
|
||||
// reaches the execution boundary so read-only command inspection does not
|
||||
// leave an audit lock handle behind (which prevents TempDir cleanup on
|
||||
// Windows). Keep an injected sink when tests or editions provide one.
|
||||
auditSink := r.auditSink
|
||||
if auditSink == nil {
|
||||
auditSink = setupAuditSink()
|
||||
}
|
||||
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
@@ -483,7 +506,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
emitAudit(r.auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
@@ -570,25 +593,25 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
||||
if err := runnerPreflightDocDownload(r, callCtx, tc, endpoint, invocation); err != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||
captureRuntimeFailure(invocation, err, overrideErr)
|
||||
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
|
||||
return executor.Result{}, overrideErr
|
||||
}
|
||||
}
|
||||
@@ -596,10 +619,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// PAT scope error: offer human-readable output and retry after authorization
|
||||
if isPatScopeError(err) {
|
||||
scopeErr := extractPatScopeError(err)
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
@@ -610,7 +633,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
return executor.Result{}, editionErr
|
||||
}
|
||||
@@ -621,7 +644,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
@@ -632,7 +655,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||
captureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
return executor.Result{}, hookErr
|
||||
}
|
||||
}
|
||||
@@ -648,10 +671,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return executor.Result{}, mcpErr
|
||||
}
|
||||
|
||||
@@ -720,7 +743,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
if err := client.EnsureInitialized(callCtx); err != nil {
|
||||
if err := runnerStdioEnsureInitialized(client, callCtx); err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio initialize failed: %v", err),
|
||||
apperrors.WithOperation("initialize"),
|
||||
@@ -728,7 +751,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
)
|
||||
}
|
||||
|
||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
||||
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio call failed: %v", err),
|
||||
@@ -889,9 +912,6 @@ func productEndpointOverride(productID string) (string, bool) {
|
||||
func resolveIdentityHeaders() map[string]string {
|
||||
id := authpkg.EnsureExists(defaultConfigDir())
|
||||
headers := id.Headers()
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type runnerCoverageFallback struct {
|
||||
result executor.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f runnerCoverageFallback) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
oldResolveMulti := runnerResolveMultiProfileSelections
|
||||
oldResolveProfile := runnerResolveProfile
|
||||
oldCachedToken := runnerGetCachedRuntimeToken
|
||||
t.Cleanup(func() {
|
||||
runnerResolveMultiProfileSelections = oldResolveMulti
|
||||
runnerResolveProfile = oldResolveProfile
|
||||
runnerGetCachedRuntimeToken = oldCachedToken
|
||||
})
|
||||
|
||||
created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2})
|
||||
if created.(*runtimeRunner).transport == nil {
|
||||
t.Fatal("runner transport was not created")
|
||||
}
|
||||
|
||||
wantErr := errors.New("profiles failed")
|
||||
runnerResolveMultiProfileSelections = func(string, string) ([]multiProfileSelection, bool, error) {
|
||||
return nil, false, wantErr
|
||||
}
|
||||
if _, err := (&runtimeRunner{}).Run(context.Background(), executor.Invocation{}); err == nil {
|
||||
t.Fatal("profile resolution error was accepted")
|
||||
}
|
||||
|
||||
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
|
||||
prefetched := make(chan struct{}, 1)
|
||||
runnerGetCachedRuntimeToken = func(context.Context) string {
|
||||
prefetched <- struct{}{}
|
||||
return ""
|
||||
}
|
||||
r := &runtimeRunner{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
directMiss := inv
|
||||
directMiss.Kind = "helper_invocation"
|
||||
if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("direct runtime miss load error = %v", err)
|
||||
}
|
||||
directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true {
|
||||
t.Fatalf("direct runtime hit = %#v, %v", got, err)
|
||||
}
|
||||
if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runSingle error = %v", err)
|
||||
}
|
||||
<-prefetched
|
||||
|
||||
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||
if selector == "a,b" {
|
||||
return nil, errors.New("not a combined profile")
|
||||
}
|
||||
if selector == "a" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if _, _, err := resolveMultiProfileSelections("", "a,b"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("profile error = %v", err)
|
||||
}
|
||||
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||
if selector == "a,b" {
|
||||
return nil, errors.New("not a combined profile")
|
||||
}
|
||||
if selector == "a" {
|
||||
return &authpkg.Profile{CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if _, _, err := resolveMultiProfileSelections("", "a,b"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("nil profile error = %v", err)
|
||||
}
|
||||
|
||||
selections := []multiProfileSelection{
|
||||
{Selector: "bad", Profile: authpkg.Profile{CorpID: "bad"}},
|
||||
{Selector: "good", Profile: authpkg.Profile{CorpID: "good"}},
|
||||
}
|
||||
r = &runtimeRunner{fallback: runnerCoverageFallback{err: wantErr}}
|
||||
result, err := r.runMultiProfile(context.Background(), inv, selections[:1])
|
||||
if err != nil || result.Response == nil {
|
||||
t.Fatalf("multi failure aggregation = %#v, %v", result, err)
|
||||
}
|
||||
r.fallback = runnerCoverageFallback{result: executor.Result{Response: map[string]any{"endpoint": "local", "value": 1}}}
|
||||
result, err = r.runMultiProfile(context.Background(), inv, selections[1:])
|
||||
if err != nil || result.Response == nil {
|
||||
t.Fatalf("multi success aggregation = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r = &runtimeRunner{
|
||||
loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}},
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{DryRun: true},
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
|
||||
got, err := r.runSingle(context.Background(), inv, false)
|
||||
if err != nil || got.Response["endpoint"] != "https://override.test" {
|
||||
t.Fatalf("catalog override = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldPreflight := runnerPreflightDocDownload
|
||||
oldCall := runnerCallTool
|
||||
oldHandle := runnerHandlePatAuthCheck
|
||||
oldRetry := runnerRetryWithPatAuthRetry
|
||||
oldCapture := runnerCaptureRuntimeFailure
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
runnerCallTool = oldCall
|
||||
runnerHandlePatAuthCheck = oldHandle
|
||||
runnerRetryWithPatAuthRetry = oldRetry
|
||||
runnerCaptureRuntimeFailure = oldCapture
|
||||
})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
oldPluginRegistry := pluginAuthRegistry
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
pluginAuthMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
pluginAuthMu.Lock()
|
||||
pluginAuthRegistry = oldPluginRegistry
|
||||
pluginAuthMu.Unlock()
|
||||
})
|
||||
|
||||
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return nil
|
||||
}
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
r := &runtimeRunner{
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{Token: "token", Timeout: 1},
|
||||
}
|
||||
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}}
|
||||
|
||||
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("preflight")
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return wantErr
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("preflight error = %v", err)
|
||||
}
|
||||
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return patErr
|
||||
}
|
||||
retrying := context.WithValue(context.Background(), patRetryingKey, true)
|
||||
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
|
||||
t.Fatalf("retrying preflight PAT = %v", err)
|
||||
}
|
||||
handled := errors.New("handled PAT")
|
||||
runnerHandlePatAuthCheck = func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) {
|
||||
return executor.Result{}, handled
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||
t.Fatalf("handled preflight PAT = %v", err)
|
||||
}
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
authErr := apperrors.NewAuth("expired")
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, authErr
|
||||
}
|
||||
overrideErr := errors.New("auth override")
|
||||
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return overrideErr }})
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, overrideErr) {
|
||||
t.Fatalf("auth override = %v", err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return nil }})
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, authErr) {
|
||||
t.Fatalf("auth passthrough = %v", err)
|
||||
}
|
||||
|
||||
scopeErr := errors.New("missing_scope calendar:read")
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, scopeErr
|
||||
}
|
||||
retried := errors.New("scope retried")
|
||||
runnerRetryWithPatAuthRetry = func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) {
|
||||
return executor.Result{}, retried
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
|
||||
t.Fatalf("scope retry = %v", err)
|
||||
}
|
||||
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, wantErr
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("generic call error = %v", err)
|
||||
}
|
||||
|
||||
patContent := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f"}}
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: patContent}, nil
|
||||
}
|
||||
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return patErr }})
|
||||
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
|
||||
t.Fatalf("edition retry PAT = %v", err)
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||
t.Fatalf("edition handled PAT = %v", err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return wantErr }})
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("edition classification = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{})
|
||||
if _, err := r.executeInvocation(retrying, "https://example.test", inv); err == nil {
|
||||
t.Fatal("built-in retrying PAT succeeded")
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||
t.Fatalf("built-in handled PAT = %v", err)
|
||||
}
|
||||
|
||||
callCount := 0
|
||||
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
|
||||
callCount++
|
||||
if callCount%2 == 0 {
|
||||
return wantErr
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "business"}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("business hook = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{})
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Text: "missing_scope mail:read"}}, Content: map[string]any{}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
|
||||
t.Fatalf("business scope retry = %v", err)
|
||||
}
|
||||
|
||||
r.scanner = coverageScanner{report: safety.Report{Scanned: true, Findings: []safety.Finding{{Pattern: "bad"}}}}
|
||||
r.enforceContentScan = true
|
||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); err == nil {
|
||||
t.Fatal("scan failure succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
|
||||
oldStdioInit := runnerStdioEnsureInitialized
|
||||
oldStdioCall := runnerStdioCallTool
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
runnerStdioEnsureInitialized = oldStdioInit
|
||||
runnerStdioCallTool = oldStdioCall
|
||||
edition.Override(oldEdition)
|
||||
StopAllStdioClients()
|
||||
})
|
||||
|
||||
client := transport.NewStdioClient("unused", nil, nil)
|
||||
RegisterStdioClient("stdio-product", client)
|
||||
r := &runtimeRunner{globalFlags: &GlobalFlags{Timeout: 1}}
|
||||
inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"}
|
||||
wantErr := errors.New("stdio failed")
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr }
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||
t.Fatalf("stdio initialize error = %v", err)
|
||||
}
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, wantErr
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||
t.Fatalf("stdio call error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||
t.Fatalf("stdio tool error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
|
||||
}
|
||||
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("stdio success = %#v, %v", got, err)
|
||||
}
|
||||
|
||||
r.globalFlags.Token = " explicit "
|
||||
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
|
||||
t.Fatalf("explicit auth token = %q", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||
_, _ = fallback()
|
||||
return "provided", nil
|
||||
}})
|
||||
r.globalFlags.Token = ""
|
||||
if got := r.resolveAuthToken(context.Background()); got != "provided" {
|
||||
t.Fatalf("provided auth token = %q", got)
|
||||
}
|
||||
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q", got)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string { return headers },
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["x-enterprise"] = "yes"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
headers := resolveIdentityHeaders()
|
||||
if headers["x-dws-channel"] != "channel" || headers["x-enterprise"] != "yes" {
|
||||
t.Fatalf("identity headers = %#v", headers)
|
||||
}
|
||||
|
||||
if got := detectBusinessError(map[string]any{"content": map[string]any{"success": false, "errorMsg": "nested"}}); got != "nested" {
|
||||
t.Fatalf("nested business error = %q", got)
|
||||
}
|
||||
if got := detectBusinessError(map[string]any{"success": false, "data": map[string]any{"success": false, "errorMsg": "nested-first"}}); got != "nested-first" {
|
||||
t.Fatalf("nested-first business error = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
t.Fatalf("create isolated test directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOME", homeDir)
|
||||
setTestHome(t, homeDir)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
@@ -470,7 +470,7 @@ func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testi
|
||||
}
|
||||
|
||||
func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
args := []string{
|
||||
@@ -518,7 +518,7 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("create isolated config directory: %v", err)
|
||||
}
|
||||
t.Setenv("HOME", sandboxRoot)
|
||||
setTestHome(t, sandboxRoot)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, []string{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTodoListAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
const (
|
||||
canonicalPath = "todo.list_todo_attachment"
|
||||
cliPath = "todo task list-attachment"
|
||||
)
|
||||
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode embedded schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != canonicalPath {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, canonicalPath)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(canonicalPath, cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
|
||||
taskID := schemaContractMap(tool["parameters"])["task-id"]
|
||||
if taskID == nil {
|
||||
t.Fatal("delivered Schema is missing --task-id")
|
||||
}
|
||||
if required, ok := taskID["required"].(bool); !ok || !required {
|
||||
t.Fatalf("task-id required = %#v, want true", taskID["required"])
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,28 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
skillLoadAccessToken = loadSkillAccessToken
|
||||
skillDownloadToTmp = downloadSkillToTmpDir
|
||||
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||||
skillNewRequest = http.NewRequestWithContext
|
||||
skillLoadTokenData = authpkg.LoadTokenData
|
||||
skillResolveTargetPath = resolveSkillTargetPath
|
||||
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||||
skillDownloadFile = downloadSkillFile
|
||||
skillExtractZip = extractSkillZip
|
||||
skillUserHomeDir = os.UserHomeDir
|
||||
skillMkdirTemp = os.MkdirTemp
|
||||
skillCreate = os.Create
|
||||
skillCreateTemp = os.CreateTemp
|
||||
skillRemoveAll = os.RemoveAll
|
||||
skillRemove = os.Remove
|
||||
skillMkdirAll = os.MkdirAll
|
||||
skillOpenFile = os.OpenFile
|
||||
skillCopy = io.Copy
|
||||
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILL_API_HOST",
|
||||
@@ -48,12 +70,14 @@ func init() {
|
||||
const (
|
||||
// legacySkillAPIHost is the legacy skill market host used by the old cli.
|
||||
legacySkillAPIHost = "https://mcp.dingtalk.com"
|
||||
// skillDownloadEndpoint is the API endpoint for downloading skills.
|
||||
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||||
// skillDownloadTimeout is the timeout for skill download operations.
|
||||
skillDownloadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// skillDownloadEndpoint is variable so tests and private distributions can
|
||||
// exercise the download flow without contacting the public service.
|
||||
var skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||||
|
||||
// downloadSkillResponse represents the API response for skill download.
|
||||
type downloadSkillResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -272,7 +296,7 @@ func newSkillAddHintCommand() *cobra.Command {
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,7 +304,7 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
||||
|
||||
tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken)
|
||||
tmpDir, err := skillDownloadToTmp(cmd.Context(), apiURL, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -295,7 +319,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
if source == "" {
|
||||
source, _ = cmd.Flags().GetString("scopes")
|
||||
}
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -304,14 +328,14 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
if source != "" {
|
||||
apiURL += "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
req, err := skillNewRequest(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := skillHTTPDo(client, req)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
@@ -359,12 +383,12 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Resolve target path
|
||||
destPath, err := resolveSkillTargetPath(target)
|
||||
destPath, err := skillResolveTargetPath(target)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||
}
|
||||
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
accessToken, err := skillLoadAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -376,7 +400,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Step 1: Get download URL from API
|
||||
fmt.Fprintf(w, "正在获取技能信息...\n")
|
||||
downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID)
|
||||
downloadResp, err := skillFetchDownloadInfo(ctx, accessToken, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -399,7 +423,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Step 2: Download the skill zip file
|
||||
fmt.Fprintf(w, "正在下载技能...\n")
|
||||
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
|
||||
tempZipPath, err := skillDownloadFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -407,7 +431,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Step 3: Extract zip to destination
|
||||
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
|
||||
if err := extractSkillZip(tempZipPath, destPath); err != nil {
|
||||
if err := skillExtractZip(tempZipPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -419,7 +443,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
|
||||
func loadSkillAccessToken() (string, error) {
|
||||
configDir := defaultConfigDir()
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
tokenData, err := skillLoadTokenData(configDir)
|
||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||
return "", skillAuthError()
|
||||
}
|
||||
@@ -462,7 +486,7 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
return "", fmt.Errorf("unsupported target")
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
homeDir, err := skillUserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
@@ -474,7 +498,7 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
|
||||
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req, err := skillNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
@@ -483,7 +507,7 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := skillHTTPDo(client, req)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
|
||||
apperrors.WithRetryable(true))
|
||||
@@ -513,14 +537,14 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
||||
}
|
||||
|
||||
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
req, err := skillNewRequest(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := skillHTTPDo(client, req)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
@@ -530,22 +554,22 @@ func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (str
|
||||
return "", parseLegacySkillAPIError(resp)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dws-skill-*")
|
||||
tmpDir, err := skillMkdirTemp("", "dws-skill-*")
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
||||
}
|
||||
|
||||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||||
destPath := filepath.Join(tmpDir, filename)
|
||||
file, err := os.Create(destPath)
|
||||
file, err := skillCreate(destPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
_ = skillRemoveAll(tmpDir)
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
if _, err := skillCopy(file, resp.Body); err != nil {
|
||||
_ = skillRemoveAll(tmpDir)
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||
}
|
||||
return tmpDir, nil
|
||||
@@ -584,7 +608,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := skillHTTPDo(client, req)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
|
||||
apperrors.WithRetryable(true))
|
||||
@@ -600,21 +624,21 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
||||
if fileName == "" {
|
||||
fileName = "skill.zip"
|
||||
}
|
||||
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
|
||||
tempFile, err := skillCreateTemp("", "dws-skill-*.zip")
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// Copy response body to temp file
|
||||
_, err = io.Copy(tempFile, resp.Body)
|
||||
_, err = skillCopy(tempFile, resp.Body)
|
||||
closeErr := tempFile.Close()
|
||||
if err != nil {
|
||||
os.Remove(tempPath)
|
||||
_ = skillRemove(tempPath)
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(tempPath)
|
||||
_ = skillRemove(tempPath)
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
|
||||
}
|
||||
|
||||
@@ -624,7 +648,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
||||
// extractSkillZip extracts a zip file to the destination directory.
|
||||
func extractSkillZip(zipPath, destDir string) error {
|
||||
// Ensure destination directory exists
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
if err := skillMkdirAll(destDir, 0755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
|
||||
}
|
||||
|
||||
@@ -653,16 +677,16 @@ func extractZipFile(file *zip.File, destDir string) error {
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
// Use 0755 to ensure we have write permission for creating files inside
|
||||
return os.MkdirAll(filePath, 0755)
|
||||
return skillMkdirAll(filePath, 0755)
|
||||
}
|
||||
|
||||
// Ensure parent directory exists with write permission
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
if err := skillMkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
|
||||
// Extract file
|
||||
srcFile, err := file.Open()
|
||||
srcFile, err := skillOpenZipFile(file)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
|
||||
}
|
||||
@@ -673,13 +697,13 @@ func extractZipFile(file *zip.File, destDir string) error {
|
||||
if fileMode&0600 == 0 {
|
||||
fileMode = 0644
|
||||
}
|
||||
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
destFile, err := skillOpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
||||
if _, err := skillCopy(destFile, srcFile); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
|
||||
}
|
||||
|
||||
@@ -689,6 +713,6 @@ func extractZipFile(file *zip.File, destDir string) error {
|
||||
// cleanupTempFile removes a temporary file, ignoring errors.
|
||||
func cleanupTempFile(path string) {
|
||||
if path != "" {
|
||||
os.Remove(path)
|
||||
_ = skillRemove(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type skillErrorReadCloser struct{ err error }
|
||||
|
||||
func (r skillErrorReadCloser) Read([]byte) (int, error) { return 0, r.err }
|
||||
func (skillErrorReadCloser) Close() error { return nil }
|
||||
|
||||
func skillCoverageResponse(status int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
|
||||
func skillCoverageCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.Flags().String("skill-id", "id", "")
|
||||
cmd.Flags().String("query", "query", "")
|
||||
cmd.Flags().String("source", "", "")
|
||||
cmd.Flags().String("scopes", "scope", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.T) {
|
||||
oldToken := skillLoadAccessToken
|
||||
oldTmp := skillDownloadToTmp
|
||||
oldHTTP := skillHTTPDo
|
||||
oldNewRequest := skillNewRequest
|
||||
oldTarget := skillResolveTargetPath
|
||||
oldFetch := skillFetchDownloadInfo
|
||||
oldDownload := skillDownloadFile
|
||||
oldExtract := skillExtractZip
|
||||
t.Cleanup(func() {
|
||||
skillLoadAccessToken = oldToken
|
||||
skillDownloadToTmp = oldTmp
|
||||
skillHTTPDo = oldHTTP
|
||||
skillNewRequest = oldNewRequest
|
||||
skillResolveTargetPath = oldTarget
|
||||
skillFetchDownloadInfo = oldFetch
|
||||
skillDownloadFile = oldDownload
|
||||
skillExtractZip = oldExtract
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
cmd := skillCoverageCommand()
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill get auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find request failure should propagate")
|
||||
}
|
||||
skillNewRequest = oldNewRequest
|
||||
skillDownloadToTmp = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill get download error = %v", err)
|
||||
}
|
||||
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill find auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find network failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusBadRequest, "bad"), nil
|
||||
}
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find HTTP failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, "{"), nil
|
||||
}
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatal("skill find malformed response should fail")
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"success":false,"errorMsg":"message"}`,
|
||||
`{"success":false,"errorCode":"code"}`,
|
||||
`{"success":false}`,
|
||||
} {
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, body), nil
|
||||
}
|
||||
if err := runSkillFind(cmd, nil); err == nil {
|
||||
t.Fatalf("skill find API failure %s should propagate", body)
|
||||
}
|
||||
}
|
||||
|
||||
skillResolveTargetPath = func(string) (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||
t.Fatal("invalid skill target should fail")
|
||||
}
|
||||
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill add auth error = %v", err)
|
||||
}
|
||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill info error = %v", err)
|
||||
}
|
||||
for _, response := range []*downloadSkillResponse{
|
||||
{Success: false, ErrorMsg: "message"},
|
||||
{Success: false, ErrorCode: "code"},
|
||||
{Success: false},
|
||||
{Success: true},
|
||||
{Success: true, Result: &downloadSkillResult{}},
|
||||
} {
|
||||
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return response, nil }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||
t.Fatalf("invalid download response %#v should fail", response)
|
||||
}
|
||||
}
|
||||
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) {
|
||||
return &downloadSkillResponse{Success: true, Result: &downloadSkillResult{DownloadURL: "url", FileName: "skill.zip"}}, nil
|
||||
}
|
||||
skillDownloadFile = func(context.Context, string, string) (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill file download error = %v", err)
|
||||
}
|
||||
skillDownloadFile = func(context.Context, string, string) (string, error) { return "temp.zip", nil }
|
||||
skillExtractZip = func(string, string) error { return fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill extraction error = %v", err)
|
||||
}
|
||||
skillExtractZip = func(string, string) error { return nil }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldHTTP := skillHTTPDo
|
||||
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
|
||||
oldHome := skillUserHomeDir
|
||||
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
|
||||
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
|
||||
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
|
||||
t.Cleanup(func() {
|
||||
skillHTTPDo = oldHTTP
|
||||
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
|
||||
skillUserHomeDir = oldHome
|
||||
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
|
||||
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
|
||||
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||
if _, err := loadSkillAccessToken(); err == nil {
|
||||
t.Fatal("invalid skill access token succeeded")
|
||||
}
|
||||
skillLoadTokenData = oldLoadToken
|
||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatal("download-info request failure should propagate")
|
||||
}
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "token"); err == nil {
|
||||
t.Fatal("skill-file request failure should propagate")
|
||||
}
|
||||
skillNewRequest = oldNewRequest
|
||||
skillUserHomeDir = func() (string, error) { return "", fail }
|
||||
if _, err := resolveSkillTargetPath("codex"); err == nil {
|
||||
t.Fatal("skill target HOME error should fail")
|
||||
}
|
||||
t.Setenv("DWS_SKILL_API_HOST", "https://skill.test/")
|
||||
if skillAPIHost() != "https://skill.test" {
|
||||
t.Fatal("skill API override was not normalized")
|
||||
}
|
||||
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatal("download-info network failure should propagate")
|
||||
}
|
||||
for _, status := range []int{http.StatusUnauthorized, http.StatusBadGateway} {
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(status, "body"), nil
|
||||
}
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatalf("download-info HTTP %d should fail", status)
|
||||
}
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: skillErrorReadCloser{err: fail}}, nil
|
||||
}
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatal("download-info body failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, "{"), nil
|
||||
}
|
||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||
t.Fatal("malformed download-info should fail")
|
||||
}
|
||||
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||
t.Fatal("temporary skill download network failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||
}
|
||||
skillMkdirTemp = func(string, string) (string, error) { return "", fail }
|
||||
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||
t.Fatal("download temp-dir failure should propagate")
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
skillMkdirTemp = func(string, string) (string, error) { return tmpDir, nil }
|
||||
skillCreate = func(string) (*os.File, error) { return nil, fail }
|
||||
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||
t.Fatal("download temp-file failure should propagate")
|
||||
}
|
||||
skillCreate = func(string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
|
||||
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||
t.Fatal("download save failure should propagate")
|
||||
}
|
||||
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||
t.Fatal("skill-file network failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusBadGateway, "body"), nil
|
||||
}
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||
t.Fatal("skill-file HTTP failure should propagate")
|
||||
}
|
||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||
}
|
||||
skillCreateTemp = func(string, string) (*os.File, error) { return nil, fail }
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||
t.Fatal("skill-file temp failure should propagate")
|
||||
}
|
||||
skillCreateTemp = func(string, string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
|
||||
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||
t.Fatal("skill-file copy failure should propagate")
|
||||
}
|
||||
skillCopy = func(w io.Writer, _ io.Reader) (int64, error) {
|
||||
_ = w.(*os.File).Close()
|
||||
return 0, nil
|
||||
}
|
||||
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||
t.Fatal("skill-file close failure should propagate")
|
||||
}
|
||||
|
||||
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := extractSkillZip("missing", "dest"); err == nil {
|
||||
t.Fatal("zip destination mkdir failure should propagate")
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "files.zip")
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
_, _ = zw.Create("dir/")
|
||||
w, _ := zw.Create("file")
|
||||
_, _ = w.Write([]byte("content"))
|
||||
header := &zip.FileHeader{Name: "mode-file", Method: zip.Store}
|
||||
header.SetMode(0o111)
|
||||
w, _ = zw.CreateHeader(header)
|
||||
_, _ = w.Write([]byte("mode"))
|
||||
_ = zw.Close()
|
||||
if err := os.WriteFile(zipPath, buf.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zr.Close()
|
||||
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := extractZipFile(zr.File[0], t.TempDir()); err == nil {
|
||||
t.Fatal("zip directory mkdir failure should propagate")
|
||||
}
|
||||
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||
t.Fatal("zip parent mkdir failure should propagate")
|
||||
}
|
||||
skillMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillOpenZipFile = func(*zip.File) (io.ReadCloser, error) { return nil, fail }
|
||||
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||
t.Fatal("zip source-open failure should propagate")
|
||||
}
|
||||
skillOpenZipFile = oldZipOpen
|
||||
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
|
||||
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||
t.Fatal("zip destination-open failure should propagate")
|
||||
}
|
||||
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "out") }
|
||||
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||
t.Fatal("zip copy failure should propagate")
|
||||
}
|
||||
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, nil }
|
||||
var usedMode os.FileMode
|
||||
skillOpenFile = func(_ string, _ int, mode os.FileMode) (*os.File, error) {
|
||||
usedMode = mode
|
||||
return os.CreateTemp(t.TempDir(), "mode")
|
||||
}
|
||||
if err := extractZipFile(zr.File[2], t.TempDir()); err != nil || usedMode != 0o644 {
|
||||
t.Fatalf("zip fallback mode = %v, %v", usedMode, err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -367,21 +368,11 @@ func TestSkillInstallCommandValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
// Setup: Create config directory with valid token
|
||||
tempDir := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
// Save a valid token
|
||||
err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "test-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
t.Cleanup(CloseFileLogger)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "invalid-target"})
|
||||
@@ -390,7 +381,7 @@ func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err = cmd.Execute()
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Execute() should have failed for invalid target")
|
||||
}
|
||||
@@ -402,8 +393,13 @@ func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
// Setup: Create config directory without token
|
||||
tempDir := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
originalLoadToken := skillLoadTokenData
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
|
||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
||||
|
||||
// Ensure the config directory exists but has no token
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
@@ -682,14 +678,17 @@ func TestSkillSearchHelpUsesWukongSourceAndKeepsScopesHidden(t *testing.T) {
|
||||
func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
|
||||
configDir := filepath.Join(t.TempDir(), "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "test-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
t.Cleanup(CloseFileLogger)
|
||||
originalLoadToken := skillLoadTokenData
|
||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "test-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
||||
|
||||
var gotSources []string
|
||||
var gotScopes []string
|
||||
@@ -711,6 +710,7 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
|
||||
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
defer CloseFileLogger()
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs(args)
|
||||
var out bytes.Buffer
|
||||
|
||||
+66
-42
@@ -41,6 +41,33 @@ const (
|
||||
skillSetupModeMulti = "multi"
|
||||
)
|
||||
|
||||
var (
|
||||
skillSetupResolveMode = resolveSkillSetupMode
|
||||
skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded
|
||||
skillSetupResolveTargets = resolveSkillSetupTargets
|
||||
skillSetupListMulti = listMultiSkillNames
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
skillSetupConfirm = confirmSkillSetup
|
||||
skillSetupInstallMono = installSkillToHomes
|
||||
skillSetupInstallMulti = installMultiSkillToHomes
|
||||
skillSetupCopyDir = copyDir
|
||||
skillSetupRunForm = (*huh.Form).Run
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
skillSetupStat = os.Stat
|
||||
skillSetupExecutable = os.Executable
|
||||
skillSetupGetwd = os.Getwd
|
||||
skillSetupUserHomeDir = os.UserHomeDir
|
||||
skillSetupRemoveAll = os.RemoveAll
|
||||
skillSetupMkdirAll = os.MkdirAll
|
||||
skillSetupWalk = filepath.Walk
|
||||
skillSetupRel = filepath.Rel
|
||||
skillSetupReadlink = os.Readlink
|
||||
skillSetupOpen = os.Open
|
||||
skillSetupOpenFile = os.OpenFile
|
||||
skillSetupCopy = io.Copy
|
||||
)
|
||||
|
||||
func newSkillSetupCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "setup",
|
||||
@@ -89,7 +116,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
out := cmd.OutOrStdout()
|
||||
errOut := cmd.ErrOrStderr()
|
||||
|
||||
mode, err := resolveSkillSetupMode(mode, autoYes, out)
|
||||
mode, err := skillSetupResolveMode(mode, autoYes, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,13 +125,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||
}
|
||||
|
||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
||||
skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcCleanup()
|
||||
|
||||
dests, err := resolveSkillSetupTargets(target, mode)
|
||||
dests, err := skillSetupResolveTargets(target, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -112,14 +139,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||
var multiSkillNames []string
|
||||
if mode == skillSetupModeMulti {
|
||||
allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
|
||||
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
if len(allMultiSkillNames) == 0 {
|
||||
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
|
||||
}
|
||||
filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
|
||||
filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw)
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
@@ -142,7 +169,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -157,9 +184,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
var installed, skipped int
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -297,7 +324,7 @@ func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||||
// listMultiSkillNames returns sorted names of subdirectories under src that
|
||||
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
|
||||
func listMultiSkillNames(src string) ([]string, error) {
|
||||
entries, err := os.ReadDir(src)
|
||||
entries, err := skillSetupReadDir(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
|
||||
}
|
||||
@@ -306,7 +333,7 @@ func listMultiSkillNames(src string) ([]string, error) {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
||||
if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
@@ -328,7 +355,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
|
||||
}
|
||||
|
||||
if autoYes || !isInteractiveTerminal() {
|
||||
if autoYes || !skillSetupInteractive() {
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||
return skillSetupModeMono, nil
|
||||
}
|
||||
@@ -346,7 +373,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
Value(&choice),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
if err := skillSetupRunForm(form); err != nil {
|
||||
return "", fmt.Errorf("交互式选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
@@ -405,7 +432,7 @@ func skillSourceCandidates(explicit, subdir string) []string {
|
||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||
roots = append(roots, env, filepath.Join(env, "skills", subdir))
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if exe, err := skillSetupExecutable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
roots = append(roots,
|
||||
filepath.Join(exeDir, "skills", subdir),
|
||||
@@ -413,13 +440,13 @@ func skillSourceCandidates(explicit, subdir string) []string {
|
||||
filepath.Join(exeDir, "..", "share", "skills", "dws"),
|
||||
)
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
if wd, err := skillSetupGetwd(); err == nil {
|
||||
roots = append(roots, filepath.Join(wd, "skills", subdir))
|
||||
}
|
||||
// User-level cache populated by install.sh / install.ps1 / npm install.js
|
||||
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
|
||||
// source even when the user has no source checkout on disk.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if home, err := skillSetupUserHomeDir(); err == nil {
|
||||
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
|
||||
}
|
||||
return roots
|
||||
@@ -431,16 +458,16 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
}
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
|
||||
fi, err := skillSetupStat(filepath.Join(path, "SKILL.md"))
|
||||
return err == nil && !fi.IsDir()
|
||||
case skillSetupModeMulti:
|
||||
entries, err := os.ReadDir(path)
|
||||
entries, err := skillSetupReadDir(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
||||
if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -458,7 +485,7 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
|
||||
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := skillSetupUserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
|
||||
}
|
||||
@@ -489,15 +516,12 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
base := filepath.Join(home, rel)
|
||||
parent := filepath.Dir(base)
|
||||
if i > 0 {
|
||||
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, agentHomeForMode(base, mode))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -530,7 +554,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
}
|
||||
}
|
||||
|
||||
if !isInteractiveTerminal() {
|
||||
if !skillSetupInteractive() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -544,7 +568,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
if err := skillSetupRunForm(form); err != nil {
|
||||
return false, fmt.Errorf("确认中止: %w", err)
|
||||
}
|
||||
return confirm, nil
|
||||
@@ -561,7 +585,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
||||
case skillSetupModeMono:
|
||||
// dest = <agent-home>/dws → agent-home = parent
|
||||
agentHome := filepath.Dir(dest)
|
||||
entries, err := os.ReadDir(agentHome)
|
||||
entries, err := skillSetupReadDir(agentHome)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -576,7 +600,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
||||
case skillSetupModeMulti:
|
||||
// dest = <agent-home> → mono 残留是 dest/dws
|
||||
monoPath := filepath.Join(dest, "dws")
|
||||
if _, err := os.Stat(monoPath); err == nil {
|
||||
if _, err := skillSetupStat(monoPath); err == nil {
|
||||
return []string{monoPath}
|
||||
}
|
||||
return nil
|
||||
@@ -588,7 +612,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
||||
// Failures emit a warning to errOut but never abort the install.
|
||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||
if err := os.RemoveAll(victim); err != nil {
|
||||
if err := skillSetupRemoveAll(victim); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||
continue
|
||||
}
|
||||
@@ -602,17 +626,17 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
|
||||
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
|
||||
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
if err := skillSetupRemoveAll(dest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
if err := skillSetupMkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := copyDir(src, dest); err != nil {
|
||||
if err := skillSetupCopyDir(src, dest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
@@ -632,7 +656,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
if err := skillSetupMkdirAll(dest, 0o755); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
|
||||
skipped += len(skillNames)
|
||||
continue
|
||||
@@ -641,12 +665,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
if err := os.RemoveAll(subDest); err != nil {
|
||||
if err := skillSetupRemoveAll(subDest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := copyDir(subSrc, subDest); err != nil {
|
||||
if err := skillSetupCopyDir(subSrc, subDest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
|
||||
skipped++
|
||||
continue
|
||||
@@ -659,22 +683,22 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
rel, err := skillSetupRel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
return skillSetupMkdirAll(target, info.Mode())
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// resolve symlink target and copy the underlying file
|
||||
resolved, err := os.Readlink(path)
|
||||
resolved, err := skillSetupReadlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -688,22 +712,22 @@ func copyDir(src, dst string) error {
|
||||
}
|
||||
|
||||
func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
in, err := skillSetupOpen(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
||||
out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
_, err = skillSetupCopy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ import (
|
||||
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
)
|
||||
|
||||
var (
|
||||
embeddedSkillStat = func(name string) (fs.FileInfo, error) {
|
||||
return fs.Stat(dwsroot.EmbeddedSkills, name)
|
||||
}
|
||||
embeddedSkillMkdirTemp = os.MkdirTemp
|
||||
embeddedSkillRemoveAll = os.RemoveAll
|
||||
embeddedSkillWalkDir = func(root string, fn fs.WalkDirFunc) error {
|
||||
return fs.WalkDir(dwsroot.EmbeddedSkills, root, fn)
|
||||
}
|
||||
embeddedSkillReadFile = dwsroot.EmbeddedSkills.ReadFile
|
||||
embeddedSkillMkdirAll = os.MkdirAll
|
||||
embeddedSkillWriteFile = os.WriteFile
|
||||
)
|
||||
|
||||
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
|
||||
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
|
||||
// override (validated as an on-disk dir). Otherwise it falls back to the skill
|
||||
@@ -50,33 +64,33 @@ func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), e
|
||||
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
|
||||
noop := func() {}
|
||||
sub := "skills/" + mode // embed.FS always uses forward slashes
|
||||
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
|
||||
if _, err := embeddedSkillStat(sub); err != nil {
|
||||
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
|
||||
tmp, err := embeddedSkillMkdirTemp("", "dws-skill-"+mode+"-")
|
||||
if err != nil {
|
||||
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(tmp) }
|
||||
cleanup := func() { _ = embeddedSkillRemoveAll(tmp) }
|
||||
|
||||
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
|
||||
walkErr := embeddedSkillWalkDir(sub, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
|
||||
dst := filepath.Join(tmp, filepath.FromSlash(rel))
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
return embeddedSkillMkdirAll(dst, 0o755)
|
||||
}
|
||||
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
|
||||
data, readErr := embeddedSkillReadFile(p)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
||||
if mkErr := embeddedSkillMkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
||||
return mkErr
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
return embeddedSkillWriteFile(dst, data, 0o644)
|
||||
})
|
||||
if walkErr != nil {
|
||||
cleanup()
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type skillSetupFileInfo struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func (i skillSetupFileInfo) Name() string { return i.name }
|
||||
func (i skillSetupFileInfo) Size() int64 { return 0 }
|
||||
func (i skillSetupFileInfo) Mode() os.FileMode { return i.mode }
|
||||
func (i skillSetupFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i skillSetupFileInfo) IsDir() bool { return i.mode.IsDir() }
|
||||
func (i skillSetupFileInfo) Sys() any { return nil }
|
||||
|
||||
func skillSetupCoverageCommand(t *testing.T, mode string, yes bool) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := newSkillSetupCommand()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(cmd)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
_ = cmd.Flags().Set("mode", mode)
|
||||
_ = cmd.Flags().Set("yes", map[bool]string{true: "true", false: "false"}[yes])
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T) {
|
||||
oldMode := skillSetupResolveMode
|
||||
oldSource := skillSetupResolveSource
|
||||
oldTargets := skillSetupResolveTargets
|
||||
oldList := skillSetupListMulti
|
||||
oldFilter := skillSetupFilterMulti
|
||||
oldConfirm := skillSetupConfirm
|
||||
oldMono := skillSetupInstallMono
|
||||
oldMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode = oldMode
|
||||
skillSetupResolveSource = oldSource
|
||||
skillSetupResolveTargets = oldTargets
|
||||
skillSetupListMulti = oldList
|
||||
skillSetupFilterMulti = oldFilter
|
||||
skillSetupConfirm = oldConfirm
|
||||
skillSetupInstallMono = oldMono
|
||||
skillSetupInstallMulti = oldMulti
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil }
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
|
||||
skillSetupListMulti = func(string) ([]string, error) { return nil, fail }
|
||||
if err := skillSetupCoverageCommand(t, skillSetupModeMulti, true).RunE(skillSetupCoverageCommand(t, skillSetupModeMulti, true), nil); err == nil {
|
||||
t.Fatal("multi list failure should propagate")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return nil, nil }
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("empty multi source should fail")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi filter failure should propagate")
|
||||
}
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupResolveMode = func(string, bool, io.Writer) (string, error) { return "unknown", nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("unknown resolved mode should fail")
|
||||
}
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("mono install failure should propagate")
|
||||
}
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
t.Cleanup(func() {
|
||||
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
|
||||
skillSetupInteractive = func() bool { return true }
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := resolveSkillSetupMode("", false, io.Discard); err == nil {
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
source := writeMultiSkillSource(t, []string{"dingtalk-doc"})
|
||||
if err := os.WriteFile(filepath.Join(source, "README"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := listMultiSkillNames(source); err != nil || len(got) != 1 {
|
||||
t.Fatalf("listed multi skills = %#v, %v", got, err)
|
||||
}
|
||||
t.Setenv("DWS_SKILL_SOURCE", source)
|
||||
if got, err := resolveSkillSetupSource("", skillSetupModeMulti); err != nil || got != source {
|
||||
t.Fatalf("environment skill source = %q, %v", got, err)
|
||||
}
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
legacyRoot := t.TempDir()
|
||||
legacyMono := filepath.Join(legacyRoot, "skills", skillSetupModeMono)
|
||||
if err := os.MkdirAll(legacyMono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyMono, "SKILL.md"), []byte("skill"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupExecutable = func() (string, error) { return filepath.Join(legacyRoot, "dws"), nil }
|
||||
skillSetupStat = oldStat
|
||||
if got, err := resolveSkillSetupSource("", skillSetupModeMono); err != nil || got != legacyMono {
|
||||
t.Fatalf("legacy executable source = %q, %v", got, err)
|
||||
}
|
||||
t.Setenv("DWS_SKILL_SOURCE", "env-source")
|
||||
if got := skillSourceCandidates("", skillSetupModeMono); len(got) < 2 {
|
||||
t.Fatalf("environment source candidates = %#v", got)
|
||||
}
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
skillSetupExecutable = func() (string, error) { return "", fail }
|
||||
skillSetupGetwd = func() (string, error) { return "", fail }
|
||||
skillSetupUserHomeDir = func() (string, error) { return "", fail }
|
||||
if _, err := resolveSkillSetupSource("", skillSetupModeMono); err == nil {
|
||||
t.Fatal("missing fallback source should fail")
|
||||
}
|
||||
_ = skillSourceCandidates("explicit", skillSetupModeMono)
|
||||
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, fail }
|
||||
if isSkillSourceRoot("missing", skillSetupModeMulti) {
|
||||
t.Fatal("unreadable multi source accepted")
|
||||
}
|
||||
skillSetupUserHomeDir = func() (string, error) { return "", fail }
|
||||
if _, err := resolveSkillSetupTargets("all", skillSetupModeMono); err == nil {
|
||||
t.Fatal("HOME failure should propagate")
|
||||
}
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "skills", "dws")
|
||||
multiRoot := filepath.Dir(monoDest)
|
||||
if err := os.MkdirAll(filepath.Join(multiRoot, "dingtalk-doc"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
|
||||
skillSetupCopyDir = func(string, string) error { return fail }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("mono remove failure not skipped")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("mono mkdir failure not skipped")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
_, skipped, _ = installSkillToHomes("src", []string{"c"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("mono copy failure not skipped")
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
|
||||
skillSetupWalk = func(string, filepath.WalkFunc) error { return fail }
|
||||
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||
t.Fatalf("walk failure = %v", err)
|
||||
}
|
||||
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
|
||||
return fn("path", skillSetupFileInfo{name: "path"}, nil)
|
||||
}
|
||||
skillSetupRel = func(string, string) (string, error) { return "", fail }
|
||||
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||
t.Fatalf("relative-path failure = %v", err)
|
||||
}
|
||||
skillSetupRel = func(string, string) (string, error) { return "file", nil }
|
||||
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
|
||||
return fn("link", skillSetupFileInfo{name: "link", mode: os.ModeSymlink}, nil)
|
||||
}
|
||||
skillSetupReadlink = func(string) (string, error) { return "", fail }
|
||||
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||
t.Fatalf("readlink failure = %v", err)
|
||||
}
|
||||
for _, target := range []string{"relative-target", "/absolute-target"} {
|
||||
skillSetupReadlink = func(string) (string, error) { return target, nil }
|
||||
_ = copyDir("src", "dst")
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||
t.Fatalf("copy mkdir failure = %v", err)
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupOpen = func(string) (*os.File, error) { return nil, fail }
|
||||
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||
t.Fatalf("copy open failure = %v", err)
|
||||
}
|
||||
in, err := os.CreateTemp(t.TempDir(), "in")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
|
||||
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
|
||||
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||
t.Fatalf("copy output-open failure = %v", err)
|
||||
}
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
|
||||
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return outFile, nil }
|
||||
skillSetupCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||
t.Fatalf("copy content failure = %v", err)
|
||||
}
|
||||
|
||||
closed, err := os.CreateTemp(t.TempDir(), "closed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if isCharDevice(closed) {
|
||||
t.Fatal("closed file is not a character device")
|
||||
}
|
||||
_ = fs.ValidPath("path")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
|
||||
// Isolate HOME so the ~/.dws/skills/<mode>/ fallback (added by the release
|
||||
// pipeline cache work) does not pick up real cached content on the
|
||||
// developer machine.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
_, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when source missing")
|
||||
@@ -99,6 +99,10 @@ func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
originalHome := skillSetupUserHomeDir
|
||||
skillSetupUserHomeDir = func() (string, error) { return home, nil }
|
||||
t.Cleanup(func() { skillSetupUserHomeDir = originalHome })
|
||||
got, err := resolveSkillSetupTargets("claude", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
@@ -106,8 +110,9 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if !strings.Contains(got[0], ".claude/skills/dws") {
|
||||
t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
|
||||
want := filepath.Join(home, ".claude", "skills", "dws")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +123,10 @@ func TestResolveSkillSetupTargetsUnknown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
originalHome := skillSetupUserHomeDir
|
||||
skillSetupUserHomeDir = func() (string, error) { return home, nil }
|
||||
t.Cleanup(func() { skillSetupUserHomeDir = originalHome })
|
||||
got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
@@ -125,11 +134,9 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if strings.HasSuffix(got[0], "/dws") {
|
||||
t.Fatalf("multi target must not end with /dws, got %s", got[0])
|
||||
}
|
||||
if !strings.HasSuffix(got[0], ".claude/skills") {
|
||||
t.Fatalf("expected suffix .claude/skills, got %s", got[0])
|
||||
want := filepath.Join(home, ".claude", "skills")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const stdioEndpointScheme = "stdio://"
|
||||
var (
|
||||
stdioMu sync.RWMutex
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stopStdio = func(client *transport.StdioClient) error { return client.Stop() }
|
||||
)
|
||||
|
||||
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
|
||||
@@ -75,7 +76,7 @@ func StopAllStdioClients() {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
for id, client := range stdioClients {
|
||||
if err := client.Stop(); err != nil {
|
||||
if err := stopStdio(client); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,7 @@ func StopStdioClient(productID string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if err := client.Stop(); err != nil {
|
||||
if err := stopStdio(client); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
||||
}
|
||||
delete(stdioClients, productID)
|
||||
@@ -108,7 +109,7 @@ func StopStdioClientsByPlugin(pluginName string) int {
|
||||
count := 0
|
||||
for id, client := range stdioClients {
|
||||
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
||||
if err := client.Stop(); err != nil {
|
||||
if err := stopStdio(client); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
delete(stdioClients, id)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setTestHome(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", home)
|
||||
if runtime.GOOS == "windows" {
|
||||
// os.UserHomeDir uses USERPROFILE on Windows rather than HOME.
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConfigureLogLevelTerminatesReplacedFileLoggers(t *testing.T) {
|
||||
firstConfig := t.TempDir()
|
||||
secondConfig := t.TempDir()
|
||||
t.Cleanup(CloseFileLogger)
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", firstConfig)
|
||||
configureLogLevel(&GlobalFlags{})
|
||||
firstLogger := FileLoggerInstance()
|
||||
t.Setenv("DWS_CONFIG_DIR", secondConfig)
|
||||
configureLogLevel(&GlobalFlags{})
|
||||
|
||||
firstLogPath := filepath.Join(firstConfig, "logs", "dws.log")
|
||||
if err := os.Remove(firstLogPath); err != nil {
|
||||
t.Fatalf("remove previous logger file: %v", err)
|
||||
}
|
||||
firstLogger.Info("late write to replaced logger")
|
||||
if _, err := os.Stat(firstLogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("replaced logger recreated its log file: %v", err)
|
||||
}
|
||||
|
||||
previousSameDirLogger := FileLoggerInstance()
|
||||
configureLogLevel(&GlobalFlags{})
|
||||
currentLogger := FileLoggerInstance()
|
||||
CloseFileLogger()
|
||||
secondLogPath := filepath.Join(secondConfig, "logs", "dws.log")
|
||||
if err := os.Remove(secondLogPath); err != nil {
|
||||
t.Fatalf("remove current logger file: %v", err)
|
||||
}
|
||||
previousSameDirLogger.Info("late write to same-directory replaced logger")
|
||||
currentLogger.Info("late write to closed current logger")
|
||||
if _, err := os.Stat(secondLogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("closed logger recreated same-directory log file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
@@ -49,8 +52,38 @@ func TestMain(m *testing.M) {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set DWS_CONFIG_DIR: " + err.Error())
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
audit.EnvAudit: "1",
|
||||
audit.EnvAuditDir: filepath.Join(tmpDir, "audit"),
|
||||
audit.EnvRetentionDays: "1",
|
||||
audit.EnvForwardURL: "",
|
||||
audit.EnvForwardToken: "",
|
||||
audit.EnvForwardRedact: "none",
|
||||
audit.EnvAuditDebug: "",
|
||||
} {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + key + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
// Keep the process-wide audit sink outside per-test TempDir trees. Cobra
|
||||
// skips PersistentPostRunE on expected command errors, and Windows cannot
|
||||
// remove a TempDir while the audit lock is still open.
|
||||
setupAuditSink()
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
|
||||
if code == 0 {
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
+17
-7
@@ -55,6 +55,16 @@ const (
|
||||
perfReportFile = "latest.json"
|
||||
)
|
||||
|
||||
var (
|
||||
timingMarshalIndent = json.MarshalIndent
|
||||
timingMkdirAll = os.MkdirAll
|
||||
timingWriteFile = os.WriteFile
|
||||
timingRemove = os.Remove
|
||||
timingRename = os.Rename
|
||||
timingReadFile = os.ReadFile
|
||||
timingUserHomeDir = os.UserHomeDir
|
||||
)
|
||||
|
||||
// timingContextKey is the context key for TimingCollector.
|
||||
type timingContextKey struct{}
|
||||
|
||||
@@ -295,7 +305,7 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
||||
}
|
||||
|
||||
report := tc.BuildReport(cliVersion, command)
|
||||
data, err := json.MarshalIndent(report, "", " ")
|
||||
data, err := timingMarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -306,22 +316,22 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
if err := timingMkdirAll(dir, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
if err := timingWriteFile(tmp, data, 0o600); err != nil {
|
||||
_ = timingRemove(tmp)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, path)
|
||||
_ = timingRename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
||||
func LoadLatestReport() (*PerfReport, error) {
|
||||
path := defaultPerfReportPath()
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := timingReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -341,7 +351,7 @@ func resolvePerfReportPath(dest string) string {
|
||||
}
|
||||
|
||||
func defaultPerfReportPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := timingUserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -282,8 +282,9 @@ func TestWriteReportIfEnabled_Auto(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
expected := filepath.Join(tmpHome, ".dws", "perf", "latest.json")
|
||||
|
||||
// Temporarily override HOME for defaultPerfReportPath
|
||||
t.Setenv("HOME", tmpHome)
|
||||
originalHome := timingUserHomeDir
|
||||
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
|
||||
t.Cleanup(func() { timingUserHomeDir = originalHome })
|
||||
t.Setenv(PerfReportEnv, "auto")
|
||||
|
||||
tc := NewTimingCollector()
|
||||
@@ -312,7 +313,9 @@ func TestWriteReportIfEnabled_NilCollector(t *testing.T) {
|
||||
|
||||
func TestLoadLatestReport(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
originalHome := timingUserHomeDir
|
||||
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
|
||||
t.Cleanup(func() { timingUserHomeDir = originalHome })
|
||||
|
||||
perfDir := filepath.Join(tmpHome, ".dws", "perf")
|
||||
if err := os.MkdirAll(perfDir, 0o700); err != nil {
|
||||
@@ -348,7 +351,9 @@ func TestLoadLatestReport(t *testing.T) {
|
||||
|
||||
func TestLoadLatestReport_NotFound(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
originalHome := timingUserHomeDir
|
||||
timingUserHomeDir = func() (string, error) { return tmpHome, nil }
|
||||
t.Cleanup(func() { timingUserHomeDir = originalHome })
|
||||
|
||||
_, err := LoadLatestReport()
|
||||
if err == nil {
|
||||
|
||||
@@ -31,6 +31,10 @@ type toolCallerAdapter struct {
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
var toolCallerDryRun = func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return (executor.EchoRunner{}).Run(ctx, invocation)
|
||||
}
|
||||
|
||||
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
|
||||
return &toolCallerAdapter{runner: runner, flags: flags}
|
||||
}
|
||||
@@ -43,7 +47,7 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
|
||||
// without catalog, auth, Keychain, endpoint or transport access.
|
||||
if a != nil && a.DryRun() {
|
||||
inv.DryRun = true
|
||||
result, err := (executor.EchoRunner{}).Run(ctx, inv)
|
||||
result, err := toolCallerDryRun(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+94
-47
@@ -31,6 +31,54 @@ var (
|
||||
ugBoldGrn = tui.Success
|
||||
)
|
||||
|
||||
type upgradeReleaseClient interface {
|
||||
FetchLatestReleaseForTrack(upgrade.ReleaseTrack) (*upgrade.ReleaseInfo, error)
|
||||
FetchReleaseByTag(string) (*upgrade.ReleaseInfo, error)
|
||||
FetchReleaseVersions(upgrade.ReleaseTrack) ([]upgrade.VersionEntry, error)
|
||||
}
|
||||
|
||||
type upgradeRollbackManager interface {
|
||||
ListBackups() ([]upgrade.BackupInfo, error)
|
||||
RollbackTo(upgrade.BackupInfo) error
|
||||
Backup(string) (string, error)
|
||||
Cleanup(int) error
|
||||
}
|
||||
|
||||
var (
|
||||
newUpgradeReleaseClient = func() upgradeReleaseClient { return upgrade.NewClient() }
|
||||
newUpgradeRollback = func() upgradeRollbackManager { return upgrade.NewRollbackManager() }
|
||||
ensureUpgradeDirs = upgrade.EnsureUpgradeDirectories
|
||||
cleanupUpgradeStale = upgrade.CleanupStaleFiles
|
||||
upgradeNeedsUpgrade = upgrade.NeedsUpgrade
|
||||
findUpgradeBinary = upgrade.FindBinaryAsset
|
||||
findUpgradeSkills = upgrade.FindSkillsAsset
|
||||
findUpgradeChecksums = upgrade.FindChecksumsAsset
|
||||
downloadUpgradeFile = upgrade.Download
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
upgradeRemoveAll = os.RemoveAll
|
||||
upgradeReadFile = os.ReadFile
|
||||
upgradeMkdirAll = os.MkdirAll
|
||||
verifyUpgradeFile = strictVerifyFile
|
||||
extractUpgradeTarGz = extractTarGz
|
||||
validateUpgradeBinary = validateNewBinary
|
||||
upgradeStat = os.Stat
|
||||
upgradeChmod = os.Chmod
|
||||
upgradeTryExecVersion = tryExecVersion
|
||||
upgradeRepairDarwin = repairDarwinBinary
|
||||
upgradeRuntimeGOOS = runtime.GOOS
|
||||
upgradeLookPath = exec.LookPath
|
||||
upgradeCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).CombinedOutput()
|
||||
}
|
||||
upgradeUserHomeDir = os.UserHomeDir
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
func newUpgradeCommand() *cobra.Command {
|
||||
@@ -128,7 +176,7 @@ type upgradeOptions struct {
|
||||
// --- dws upgrade --check ---
|
||||
|
||||
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
|
||||
client := upgrade.NewClient()
|
||||
client := newUpgradeReleaseClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
|
||||
@@ -140,7 +188,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
|
||||
needsUpgrade := upgradeNeedsUpgrade(currentVer, latest.Version)
|
||||
|
||||
if format == "json" {
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
@@ -184,7 +232,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
|
||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
|
||||
client := upgrade.NewClient()
|
||||
client := newUpgradeReleaseClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
|
||||
@@ -264,7 +312,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.
|
||||
// --- dws upgrade --rollback ---
|
||||
|
||||
func runUpgradeRollback(yes bool) error {
|
||||
rm := upgrade.NewRollbackManager()
|
||||
rm := newUpgradeRollback()
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
@@ -341,13 +389,13 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
|
||||
|
||||
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
|
||||
if err := ensureUpgradeDirs(); err != nil {
|
||||
return fmt.Errorf("初始化目录结构失败: %w", err)
|
||||
}
|
||||
|
||||
upgrade.CleanupStaleFiles()
|
||||
cleanupUpgradeStale()
|
||||
|
||||
client := upgrade.NewClient()
|
||||
client := newUpgradeReleaseClient()
|
||||
var release *upgrade.ReleaseInfo
|
||||
var err error
|
||||
|
||||
@@ -365,7 +413,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
|
||||
if !opts.force && !upgradeNeedsUpgrade(currentVer, release.Version) {
|
||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||
return nil
|
||||
}
|
||||
@@ -384,11 +432,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
// any side effect (no backup, no download, no replace). Matches the global
|
||||
// flag's contract: "预览操作内容,不实际执行".
|
||||
if opts.dryRun {
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
binaryAsset, err := findUpgradeBinary(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
|
||||
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
||||
return nil
|
||||
}
|
||||
@@ -404,21 +452,21 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
binaryAsset, err := findUpgradeBinary(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
||||
tmpDir, err := upgradeMkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
||||
if err != nil {
|
||||
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
|
||||
tmpDir, err = upgradeMkdirTemp("", "dws-upgrade-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
defer upgradeRemoveAll(tmpDir)
|
||||
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
|
||||
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
|
||||
const totalSteps = 5
|
||||
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
|
||||
@@ -431,7 +479,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
|
||||
// --- Step 1: Backup ---
|
||||
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
|
||||
rm := upgrade.NewRollbackManager()
|
||||
rm := newUpgradeRollback()
|
||||
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
|
||||
if backupErr != nil {
|
||||
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
|
||||
@@ -441,11 +489,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
|
||||
// Fetch checksums.txt (needed for strict verification of both binary and skills)
|
||||
var checksumsContent string
|
||||
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
|
||||
checksumsAsset := findUpgradeChecksums(release.Assets)
|
||||
if checksumsAsset != nil {
|
||||
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
||||
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
||||
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
|
||||
if _, dlErr := downloadUpgradeFile(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
||||
if data, readErr := upgradeReadFile(checksumsPath); readErr == nil {
|
||||
checksumsContent = string(data)
|
||||
}
|
||||
}
|
||||
@@ -457,7 +505,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Print(progressPrefix)
|
||||
start := time.Now()
|
||||
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
|
||||
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
||||
n, err := downloadUpgradeProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
||||
func(percent float64, downloaded, total int64) {
|
||||
bar := progressBar(percent)
|
||||
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
|
||||
@@ -471,12 +519,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
|
||||
var skillsZipPath string
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
skillsAsset := findUpgradeSkills(release.Assets)
|
||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||
|
||||
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
|
||||
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
|
||||
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
||||
if _, dlErr := downloadUpgradeFile(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包下载失败: %w", dlErr)
|
||||
}
|
||||
@@ -486,12 +534,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
}
|
||||
|
||||
// --- Step 3: Verify SHA256 (binary + skills together) ---
|
||||
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
||||
if err := verifyUpgradeFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
||||
skillsAsset := findUpgradeSkills(release.Assets)
|
||||
if err := verifyUpgradeFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -500,22 +548,22 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s 解压并验证...", stepFmt(4))
|
||||
extractDir := filepath.Join(tmpDir, "extracted")
|
||||
if strings.HasSuffix(binaryAsset.Name, ".zip") {
|
||||
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
|
||||
if err := extractUpgradeZip(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
|
||||
if err := extractUpgradeTarGz(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
}
|
||||
binaryPath := upgrade.FindBinaryInDir(extractDir)
|
||||
binaryPath := findExtractedBinary(extractDir)
|
||||
if binaryPath == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
|
||||
}
|
||||
if err := validateNewBinary(binaryPath, release.Version); err != nil {
|
||||
if err := validateUpgradeBinary(binaryPath, release.Version); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("验证失败: %w", err)
|
||||
}
|
||||
@@ -523,12 +571,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
var skillSrc string
|
||||
if hasSkills {
|
||||
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
|
||||
os.MkdirAll(skillsExtractDir, 0755)
|
||||
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
|
||||
_ = upgradeMkdirAll(skillsExtractDir, 0755)
|
||||
if err := extractUpgradeZip(skillsZipPath, skillsExtractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
|
||||
}
|
||||
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
|
||||
skillSrc = locateUpgradeSkill(skillsExtractDir)
|
||||
if skillSrc == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
|
||||
@@ -542,13 +590,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
|
||||
// --- Step 5: Replace binary + install skills ---
|
||||
fmt.Printf(" %s 替换并安装...", stepFmt(5))
|
||||
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
|
||||
if err := replaceUpgradeSelf(binaryPath); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("替换二进制失败: %w", err)
|
||||
}
|
||||
|
||||
if hasSkills {
|
||||
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
|
||||
result, installErr := installUpgradeSkills(skillSrc)
|
||||
if installErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||
@@ -626,24 +674,24 @@ func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent s
|
||||
|
||||
// validateNewBinary checks the downloaded binary is valid.
|
||||
func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||
info, err := os.Stat(binaryPath)
|
||||
info, err := upgradeStat(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("文件不存在: %w", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("文件为空")
|
||||
}
|
||||
if err := os.Chmod(binaryPath, 0755); err != nil {
|
||||
if err := upgradeChmod(binaryPath, 0755); err != nil {
|
||||
return fmt.Errorf("设置执行权限失败: %w", err)
|
||||
}
|
||||
|
||||
out, err := tryExecVersion(binaryPath)
|
||||
out, err := upgradeTryExecVersion(binaryPath)
|
||||
if err != nil {
|
||||
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
|
||||
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
|
||||
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
|
||||
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
|
||||
out, err = tryExecVersion(binaryPath)
|
||||
if upgradeRuntimeGOOS == "darwin" && isLikelyAMFIKill(err) {
|
||||
if repairErr := upgradeRepairDarwin(binaryPath); repairErr == nil {
|
||||
out, err = upgradeTryExecVersion(binaryPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -678,12 +726,12 @@ func isLikelyAMFIKill(err error) bool {
|
||||
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
|
||||
func repairDarwinBinary(binaryPath string) error {
|
||||
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
|
||||
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
|
||||
_, _ = upgradeCommandOutput("xattr", "-d", "com.apple.quarantine", binaryPath)
|
||||
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
if _, err := upgradeLookPath("codesign"); err != nil {
|
||||
return fmt.Errorf("codesign 不可用: %w", err)
|
||||
}
|
||||
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
|
||||
out, err := upgradeCommandOutput("codesign", "--force", "--sign", "-", binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -692,9 +740,8 @@ func repairDarwinBinary(binaryPath string) error {
|
||||
|
||||
// extractTarGz extracts a .tar.gz file using the system tar command.
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
os.MkdirAll(destDir, 0755)
|
||||
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = upgradeMkdirAll(destDir, 0755)
|
||||
if out, err := upgradeCommandOutput("tar", "xzf", archivePath, "-C", destDir); err != nil {
|
||||
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
@@ -867,7 +914,7 @@ func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
|
||||
}
|
||||
|
||||
func shortenHome(path string) string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
homeDir, err := upgradeUserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
|
||||
Loaded 100 of 684 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user