mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
248 Commits
fix/instal
...
v1.0.29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0b68dc0e | ||
|
|
cc16c4d2d7 | ||
|
|
1ee7f22ee5 | ||
|
|
b612dde19e | ||
|
|
4181174352 | ||
|
|
1180baac61 | ||
|
|
db1a3fc0a6 | ||
|
|
7c6abb3834 | ||
|
|
4c63198237 | ||
|
|
c0fbe54ef6 | ||
|
|
4ba39ef392 | ||
|
|
25c72ced6f | ||
|
|
0ed63b02e4 | ||
|
|
5352e6a90a | ||
|
|
16f1a0f320 | ||
|
|
4d625420b0 | ||
|
|
4aceae9bff | ||
|
|
44ffa98b89 | ||
|
|
f9792f056e | ||
|
|
6e22a7e518 | ||
|
|
29a98966a0 | ||
|
|
a81d07ca4f | ||
|
|
e754b3bc1b | ||
|
|
a6de8360f0 | ||
|
|
88d7ec8ee7 | ||
|
|
90757887b2 | ||
|
|
88d4e3bd90 | ||
|
|
7c68639b31 | ||
|
|
8b80810fa0 | ||
|
|
eed802c814 | ||
|
|
8f410ab140 | ||
|
|
d9b9f094cf | ||
|
|
b65147f208 | ||
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad | ||
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 | ||
|
|
b37adfd0ee | ||
|
|
082275f32b | ||
|
|
2eb9fae575 | ||
|
|
418192507e | ||
|
|
7752afab96 | ||
|
|
f7a56f38b1 | ||
|
|
ea056d132e | ||
|
|
7fc963f455 | ||
|
|
520acb618c | ||
|
|
dce2beb91c | ||
|
|
97968b6ef2 | ||
|
|
6bb988a655 | ||
|
|
4422265d5f | ||
|
|
7eb0ba3257 | ||
|
|
af2398d636 | ||
|
|
138bf36bb3 | ||
|
|
0bbd0f2c7d | ||
|
|
fc9f9c1f26 | ||
|
|
fc22e9a04b | ||
|
|
9ba0d15161 | ||
|
|
b8d0f96265 | ||
|
|
2e4cfb4921 | ||
|
|
23066c8eee | ||
|
|
c09b03f854 | ||
|
|
4d4508dfd7 | ||
|
|
05d8137c7d | ||
|
|
17a85d319d | ||
|
|
a16eb24ba9 | ||
|
|
f6f242ed57 | ||
|
|
7124b18baa | ||
|
|
78d92de6af | ||
|
|
8ec95a4e39 | ||
|
|
fe9dc4ce6a | ||
|
|
1e2144ee08 | ||
|
|
20fba1e601 | ||
|
|
97f817d088 | ||
|
|
ddf6f0cb7d | ||
|
|
834a899e2b | ||
|
|
aa48d70d7a | ||
|
|
2e7a11a8e8 | ||
|
|
5d129314c0 | ||
|
|
7d0ceb5d58 | ||
|
|
fd4c35b10e | ||
|
|
d92f0a2204 | ||
|
|
6f444c5dc2 | ||
|
|
e42033f5b5 | ||
|
|
24afe39516 | ||
|
|
d3340f5006 | ||
|
|
d69d0a0bb7 | ||
|
|
ce80b3bc46 | ||
|
|
593025d298 | ||
|
|
f52ea47163 | ||
|
|
10f1f2e2ea | ||
|
|
1df5094b46 | ||
|
|
600fa50517 | ||
|
|
fc6d722f05 | ||
|
|
c7ced37959 | ||
|
|
81d22c6f34 | ||
|
|
6b7263a53b | ||
|
|
bc6590abef | ||
|
|
295f1d513e | ||
|
|
e6f3fa2575 | ||
|
|
776ee686ff | ||
|
|
4da6d610e2 | ||
|
|
3f4352d50c | ||
|
|
543a8365d6 | ||
|
|
0192cee859 | ||
|
|
18e227f281 | ||
|
|
7e9beec422 | ||
|
|
462d38e8f7 | ||
|
|
e4d263948c | ||
|
|
11191df703 | ||
|
|
e23b3a8dc6 | ||
|
|
f3699298aa | ||
|
|
018eeb6414 | ||
|
|
3e5dc3262f | ||
|
|
c13644a247 | ||
|
|
cb301a3d1a | ||
|
|
04e3a28529 | ||
|
|
e02c442aea | ||
|
|
fbed6beac3 | ||
|
|
e15aef922e | ||
|
|
ccc27ce417 | ||
|
|
24e0bb38eb | ||
|
|
9057299430 | ||
|
|
9e891b758e | ||
|
|
293a9f896f | ||
|
|
0a0cdc8879 | ||
|
|
67e51ec8d7 | ||
|
|
5943a20e2b | ||
|
|
cd666422ac | ||
|
|
9acd121259 | ||
|
|
1262aac480 | ||
|
|
abb02cd46c | ||
|
|
db7d3cb64d | ||
|
|
5134719da9 | ||
|
|
5a0e1d3dd9 | ||
|
|
09e60eeaf4 | ||
|
|
4f90fd3b77 | ||
|
|
6212513c43 | ||
|
|
e8df0ea63e | ||
|
|
6d0d687be2 | ||
|
|
148a04a7f8 | ||
|
|
ba19bd9f93 | ||
|
|
830fb3bbe5 | ||
|
|
1ad7cfab5b | ||
|
|
5280517d4b | ||
|
|
3ad6f2fac4 | ||
|
|
be79485fe3 | ||
|
|
94bba91224 | ||
|
|
0d50616e77 | ||
|
|
d5784eac28 | ||
|
|
663c24aadf | ||
|
|
6ad25cd452 | ||
|
|
c442fa27d1 | ||
|
|
35a8288baf | ||
|
|
79379fbc6f | ||
|
|
d0ab8ee7dc | ||
|
|
1608f95632 | ||
|
|
e10bf8eca2 | ||
|
|
c1d6042552 | ||
|
|
656c16a47f | ||
|
|
9dfaff4664 | ||
|
|
f0e724cbd4 | ||
|
|
03ba542a60 | ||
|
|
5fa68ccaa0 | ||
|
|
1583af7fc0 | ||
|
|
44e7b5b477 | ||
|
|
66ec27f6e1 | ||
|
|
162c25527b | ||
|
|
0c7a930fc3 | ||
|
|
ec9e67c21a | ||
|
|
74e4a97f52 | ||
|
|
fe4123436f | ||
|
|
052e2112bf | ||
|
|
76a834e928 | ||
|
|
20761fa56a | ||
|
|
2a301246f9 | ||
|
|
abc374f1a3 | ||
|
|
2910cde73a | ||
|
|
7fdc162ff7 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb | ||
|
|
1ff2dc578e | ||
|
|
69ae326d01 | ||
|
|
e07842d3b5 | ||
|
|
a9c07cebb6 | ||
|
|
f6a31e0853 | ||
|
|
bd5a33c0b7 | ||
|
|
3242ca6f7f | ||
|
|
368ec7e753 | ||
|
|
9f81e7e567 | ||
|
|
a00dfad56a | ||
|
|
8c799d5a9f | ||
|
|
474cb30a48 | ||
|
|
e8e0c6fc5a | ||
|
|
b8f71d50d1 | ||
|
|
46468a900c | ||
|
|
f59f263138 | ||
|
|
51d07be18a | ||
|
|
344ff88701 | ||
|
|
78ff1e7968 | ||
|
|
fa16fe1976 | ||
|
|
d8b0865814 | ||
|
|
d026741532 | ||
|
|
cd7a2363e5 | ||
|
|
353c473e52 | ||
|
|
76fac115ed | ||
|
|
d2a834051d | ||
|
|
d30a9472c3 | ||
|
|
b8fa2b3f80 | ||
|
|
6ec19cbc84 | ||
|
|
d7363b0481 | ||
|
|
5f3915b25c | ||
|
|
4e65ea808e | ||
|
|
d7262b7dc5 | ||
|
|
c16a021ac6 | ||
|
|
fd9ee6afd6 | ||
|
|
69cf9f206e | ||
|
|
99b8aaa556 | ||
|
|
b4a26b2cdc |
@@ -6,3 +6,6 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
Normal file
116
.github/workflows/arch-audit.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Architecture Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Monday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Dead code detection
|
||||
run: |
|
||||
echo "## Dead Code" >> report.md
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
|
||||
count=$(wc -l < deadcode.txt | tr -d ' ')
|
||||
echo "Found **$count** unreachable functions" >> report.md
|
||||
echo '```' >> report.md
|
||||
cat deadcode.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Package complexity
|
||||
run: |
|
||||
echo "## Package Complexity" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "| Package | Files | Lines | Deps |" >> report.md
|
||||
echo "|---------|-------|-------|------|" >> report.md
|
||||
found=0
|
||||
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
|
||||
dir=$(go list -f '{{.Dir}}' "$pkg")
|
||||
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
|
||||
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
|
||||
deps=$(go list -f '{{len .Imports}}' "$pkg")
|
||||
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
|
||||
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "| _(none)_ | | | |" >> report.md
|
||||
fi
|
||||
|
||||
- name: Dependency freshness
|
||||
run: |
|
||||
echo "## Outdated Dependencies" >> report.md
|
||||
echo '```' >> report.md
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Shortcut domains without E2E tests:" >> report.md
|
||||
echo "" >> report.md
|
||||
found=0
|
||||
for domain in $(ls -d shortcuts/*/); do
|
||||
name=$(basename "$domain")
|
||||
if [ "$name" = "common" ]; then continue; fi
|
||||
if [ ! -d "tests/cli_e2e/$name" ]; then
|
||||
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "All shortcut domains have E2E test directories." >> report.md
|
||||
fi
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
echo "## Coverage" >> report.md
|
||||
packages=$(go list ./... | grep -v 'tests/cli_e2e')
|
||||
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
|
||||
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
|
||||
echo "Current total coverage: **${total:-n/a}**" >> report.md
|
||||
|
||||
- name: Publish report
|
||||
run: |
|
||||
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cat report.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: arch-audit-${{ github.run_number }}
|
||||
path: report.md
|
||||
retention-days: 90
|
||||
334
.github/workflows/ci.yml
vendored
Normal file
334
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
fast-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "$unformatted"
|
||||
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
|
||||
exit 1
|
||||
fi
|
||||
- name: Check go.mod tidiness
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
|
||||
exit 1
|
||||
fi
|
||||
- name: Coverage summary
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [ ! -f coverage.txt ]; then
|
||||
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Dead code check (incremental)
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
git worktree remove -f /tmp/dc-base 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
git worktree remove -f /tmp/dc-base
|
||||
|
||||
# Only new dead code blocks the PR
|
||||
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
|
||||
if [ -s /tmp/dc-new.txt ]; then
|
||||
echo "::group::New dead code"
|
||||
cat /tmp/dc-new.txt
|
||||
echo "::endgroup::"
|
||||
echo "::error::New dead code detected — remove unreachable functions before merging"
|
||||
exit 1
|
||||
fi
|
||||
echo "No new dead code introduced"
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Gitleaks
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
- name: govulncheck
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
license-header:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
run: |
|
||||
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo ""
|
||||
echo "::error::One or more CI jobs failed — see table above"
|
||||
exit 1
|
||||
fi
|
||||
135
.github/workflows/cli-e2e.yml
vendored
135
.github/workflows/cli-e2e.yml
vendored
@@ -1,135 +0,0 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
58
.github/workflows/coverage.yml
vendored
58
.github/workflows/coverage.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
28
.github/workflows/gitleaks.yml
vendored
28
.github/workflows/gitleaks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
26
.github/workflows/license-header.yml
vendored
26
.github/workflows/license-header.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
60
.github/workflows/lint.yml
vendored
60
.github/workflows/lint.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -45,6 +45,15 @@ jobs:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
43
.github/workflows/tests.yml
vendored
43
.github/workflows/tests.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,5 +33,9 @@ tests/mail/reports/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
@@ -54,6 +54,12 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -72,6 +78,16 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
# intentionally allowed since they don't bypass the runtime layer.
|
||||
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
|
||||
msg: >-
|
||||
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
|
||||
instead of constructing raw HTTP. The runtime handles auth, headers,
|
||||
and error normalization. (Constants and helpers like http.MethodPost,
|
||||
http.StatusOK, http.StatusText remain allowed.)
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
|
||||
msg: "use the corresponding vfs.Xxx() from internal/vfs"
|
||||
@@ -100,6 +116,16 @@ linters:
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
# ── output: shortcuts must use ctx.Out() ──
|
||||
- pattern: fmt\.Print(f|ln)?\b
|
||||
msg: >-
|
||||
use ctx.Out() or ctx.OutFormat() for structured JSON output.
|
||||
fmt.Print* bypasses the output envelope and breaks --jq/--format.
|
||||
# ── logging: shortcuts must return errors, not log.Fatal ──
|
||||
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
|
||||
msg: >-
|
||||
use structured error return, not log.Fatal/Panic.
|
||||
Shortcuts must return errors to the framework for proper exit code handling.
|
||||
# ── filepath: functions that access the filesystem ──
|
||||
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
|
||||
msg: >-
|
||||
|
||||
47
AGENTS.md
47
AGENTS.md
@@ -15,12 +15,30 @@ make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
`lark-cli` emits two notice types into JSON envelope `_notice` to nudge AI agents toward fixes:
|
||||
|
||||
- `_notice.update` — a newer binary is available on npm
|
||||
- `_notice.skills` — locally installed skills are out of sync with the running binary
|
||||
|
||||
To suppress them in non-CI scripts (CI envs are auto-skipped):
|
||||
|
||||
| Env var | Effect |
|
||||
|---------|--------|
|
||||
| `LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1` | Suppress `_notice.update` |
|
||||
| `LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1` | Suppress `_notice.skills` |
|
||||
|
||||
Both notices recommend the same fix command: `lark-cli update`. The skills notice's `current` field is `""` when skills have never been synced (cold start) and a version string when synced for an older binary (drift).
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
2. `go vet ./...`
|
||||
3. `gofmt -l .` — must produce no output
|
||||
4. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
@@ -76,3 +94,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
### E2E Testing
|
||||
|
||||
**Dry-run E2E (required for every shortcut change)**
|
||||
- Validates request structure without calling real APIs
|
||||
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
|
||||
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
|
||||
- No secrets needed — runs on fork PRs
|
||||
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
|
||||
|
||||
**Live E2E (required for new flows or behavior changes)**
|
||||
- Validates real API round-trips
|
||||
- Place in `tests/cli_e2e/<domain>/`
|
||||
- Must be self-contained: create -> use -> cleanup
|
||||
- Needs bot credentials (CI secrets, skipped on fork PRs)
|
||||
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
|
||||
|
||||
| Change | Dry-run E2E | Live E2E |
|
||||
|--------|:-----------:|:--------:|
|
||||
| New shortcut | Required | Required |
|
||||
| Modify shortcut flags/params | Required | If behavior changes |
|
||||
| Shortcut bug fix | Required | If regression risk |
|
||||
| Internal refactor (no shortcut impact) | Not needed | Not needed |
|
||||
|
||||
477
CHANGELOG.md
477
CHANGELOG.md
@@ -2,6 +2,460 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
|
||||
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Silence misleading "skills not installed" startup notice (#801)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine data analysis SOP wording (#784, #849)
|
||||
- Update README capability descriptions (#793)
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
|
||||
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Handle duplicate remote sync paths (#803)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
|
||||
|
||||
## [v1.0.27] - 2026-05-09
|
||||
|
||||
### Features
|
||||
|
||||
- **config**: Add `lark-channel` as a bind source (#786)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Fix installation errors when PowerShell is disabled by Group Policy (#789)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **task**: Clarify task member id types in references (#777)
|
||||
|
||||
## [v1.0.26] - 2026-05-08
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `message_app_link` to message outputs (#668)
|
||||
- **auth**: Add scope hint for missing authorization errors (#776)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Clean error detail output (#783)
|
||||
- **whiteboard**: Reclassify `+update` as `write` risk (#775)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add data integrity and write-confirmation rules (#749)
|
||||
|
||||
## [v1.0.25] - 2026-05-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add skills version drift notice and unify update flow (#723)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove misleading default value from `--as` flag help text (#769)
|
||||
- Handle negative truncate lengths (#744)
|
||||
- Reject invalid JSON pointer escapes (#741)
|
||||
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify base `user_open_id` guidance (#763)
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add sheet management shortcuts (#722)
|
||||
- **base**: Support batch record get and delete (#630)
|
||||
- **task**: Add upload task attachment shortcut (#736)
|
||||
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Handle missing scopes and device flow improvements (#752)
|
||||
- Add url to markdown `+create` output (#753)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refine field update conversion guidance (#748)
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
|
||||
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
|
||||
- **drive**: Add `+status` shortcut for content-hash diff (#692)
|
||||
- **drive**: Support `--file-name` for drive export (#685)
|
||||
- **base**: Add markdown output for record reads (#726)
|
||||
- **minutes**: Add media upload shortcut (#725)
|
||||
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
|
||||
- **cmdutil**: Support `@file` for params and data (#724)
|
||||
- Add markdown shortcuts and skill docs (#704)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide lark-doc v2 usage (#710)
|
||||
- **minutes**: Clarify minutes file-to-notes routing (#732)
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
|
||||
- **task**: Add resource agent & `agent_task_step_info` (#693)
|
||||
- **task**: Support app task members by id (#712)
|
||||
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
|
||||
- **slides**: Add slide templates with template-first skill guidance (#684)
|
||||
- **mail**: Support calendar events in emails (#646)
|
||||
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Make Windows zip extraction resilient (#713)
|
||||
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify base search routing (#708)
|
||||
- **base**: Align base skills and view config contracts (#653)
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
|
||||
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
|
||||
- **common**: Backfill resource URL when create APIs omit it (#680)
|
||||
- **risk**: Add risk tiering for command sensitivity classification (#633)
|
||||
- **okr**: Add progress records support (#574)
|
||||
- **calendar**: Enhance event search and meeting room finding (#679)
|
||||
- **event**: Add event subscription & consume system (#654)
|
||||
- **drive**: Extend `+add-comment` to support slides targets (#674)
|
||||
- **slides**: Add font management for slides (#681)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cmdutil**: Default flag completions to disabled (#688)
|
||||
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
|
||||
- **readme**: Fix readme statistics (#691)
|
||||
|
||||
## [v1.0.20] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+search` shortcut with flat filter flags (#658)
|
||||
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
|
||||
- **calendar**: Add `+update` shortcut (#678)
|
||||
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
|
||||
- **pagination**: Preserve pagination state on truncation and natural end (#659)
|
||||
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
|
||||
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Add missing import command examples (#669)
|
||||
- **readme**: Add Project (Meegle) to Features table (#660)
|
||||
|
||||
## [v1.0.19] - 2026-04-24
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add read receipt support — `--request-receipt` on compose, `+send-receipt` / `+decline-receipt` for response
|
||||
- **doc**: Add v2 API for `docs +create` / `+fetch` / `+update` (#638)
|
||||
- **im**: Request thread roots for chat message list (#635)
|
||||
- **drive**: Support wiki node targets in `+upload` (#611)
|
||||
- **config**: Block `auth` / `config` when external credential provider is active (#627)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.10` in `lark-whiteboard` skill (#649)
|
||||
|
||||
## [v1.0.18] - 2026-04-23
|
||||
|
||||
### Features
|
||||
|
||||
- **base**: Support `.base` import and export for bitable (#599)
|
||||
- **config**: Add `config bind` for per-Agent credential isolation (#515)
|
||||
- **slides**: Add `+replace-slide` shortcut for block-level XML edits (#516)
|
||||
- **wiki**: Add `+delete-space` shortcut with async task polling (#610)
|
||||
- **doc**: Add `--from-clipboard` flag to `docs +media-insert` (#508)
|
||||
- **minutes**: Unify minute artifacts output to `./minutes/{minute_token}/` (#604)
|
||||
- Add configurable content-safety scanning (#606)
|
||||
- **install**: Add SHA-256 checksum verification to `install.js` (#592)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `^0.2.9` (#617)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Escape angle brackets in comment text (#632)
|
||||
- **im**: Unify `messages-search` pagination int flags (#446)
|
||||
- **im**: Fix markdown URL rendering issues in post content (#206)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine record cell value guidance (#636)
|
||||
|
||||
## [v1.0.17] - 2026-04-22
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
|
||||
- **drive**: Add `+apply-permission` to request doc access (#588)
|
||||
- Support record share link (#466)
|
||||
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
|
||||
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Add default-table follow-up hint to `base-create` (#600)
|
||||
- Skip flag-completion registration outside completion path (#598)
|
||||
- Add `record-share-link-create` in `SKILL.md` (#597)
|
||||
- **mail**: Remove leftover conflict marker in skill docs (#594)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
|
||||
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
|
||||
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
|
||||
|
||||
## [v1.0.16] - 2026-04-21
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Support large email attachments (#537)
|
||||
- **mail**: Add draft preview URL to draft operations (#438)
|
||||
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
|
||||
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
|
||||
- **calendar**: Support event share link and error details (#583)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
|
||||
- **docs**: Validate `--selection-by-title` format early (#256)
|
||||
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
|
||||
- **auth**: Simplify scope reporting in login flow (#582)
|
||||
|
||||
## [v1.0.15] - 2026-04-20
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add float image shortcuts (#494)
|
||||
- **approval**: Document `remind` and `initiated` methods in skill (#554)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Preserve attachment metadata on base uploads (#563)
|
||||
- **base**: Fix role view and record default permission on edit (#530)
|
||||
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
|
||||
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
|
||||
- **install**: Refine install wizard messages (#529)
|
||||
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
|
||||
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
|
||||
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
|
||||
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
|
||||
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
|
||||
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
|
||||
|
||||
### CI
|
||||
|
||||
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
|
||||
|
||||
## [v1.0.12] - 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
|
||||
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
|
||||
- **mail**: Return recall hints for sent emails when recall is available (#481)
|
||||
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
|
||||
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support im oapi range download for large files (#283)
|
||||
- **sheets**: Add filter view and condition shortcuts (#422)
|
||||
- **wiki**: Add wiki move shortcut with async task polling (#436)
|
||||
- **drive**: Add drive `+create-shortcut` shortcut (#432)
|
||||
- **drive**: Add drive files patch metadata API (#444)
|
||||
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Support large base attachment uploads (#441)
|
||||
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
|
||||
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
|
||||
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
|
||||
- **mail**: Restrict `--output-dir` to current working directory (#376)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
|
||||
- **task**: Document sections API resources, permissions, and URL parsing (#430)
|
||||
- **doc**: Clarify when markdown escaping is needed (#312)
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
|
||||
- Add attendance `user_task.query` (#405)
|
||||
- Support minutes search (#359)
|
||||
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
|
||||
- **slides**: Return presentation URL in slides `+create` output (#425)
|
||||
- **sheets**: Add dimension shortcuts for row/column operations (#413)
|
||||
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
|
||||
- **drive**: Add drive folder delete shortcut with async task polling (#415)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Add guide for granting document permission to current bot (#414)
|
||||
|
||||
## [v1.0.8] - 2026-04-10
|
||||
|
||||
### Features
|
||||
|
||||
- Add `update` command with self-update, verification, and rollback (#391)
|
||||
- Add `--file` flag for multipart/form-data file uploads (#395)
|
||||
- Support file comment reply reactions (#380)
|
||||
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
|
||||
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
|
||||
- **base**: Add `+record-search` for keyword-based record search (#328)
|
||||
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
|
||||
- **base**: Add record field filters (#327)
|
||||
- **base**: Optimize workflow skills (#345)
|
||||
- **calendar**: Add room find workflow (#403)
|
||||
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
|
||||
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve error hints for sandbox and initialization issues (#384)
|
||||
- Fix markdown line breaks support (#338)
|
||||
- Return raw base field and view responses (#378)
|
||||
- **base**: Return raw table list response and clarify sort help (#393)
|
||||
- **calendar**: Add default video meeting to `+create` (#383)
|
||||
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Document Base attachment download via docs `+media-download` (#404)
|
||||
- Reorganize lark-base skill guidance (#374)
|
||||
|
||||
## [v1.0.7] - 2026-04-09
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
|
||||
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
|
||||
- **vc**: Extract note doc tokens from calendar event relation API (#333)
|
||||
- **wiki**: Add wiki node create shortcut (#320)
|
||||
- **sheets**: Add `+write-image` shortcut (#343)
|
||||
- **docs**: Add media-preview shortcut (#334)
|
||||
- **docs**: Add support for additional search filters (#353)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
|
||||
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
|
||||
- **run**: Add missing binary check for lark-cli execution (#362)
|
||||
- **config**: Validate appId and appSecret keychain key consistency (#295)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Route base import guidance to drive `+import` (#368)
|
||||
- Migrate mail shortcuts to FileIO (#356)
|
||||
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
|
||||
- Migrate base shortcuts to FileIO (#347)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
|
||||
|
||||
## [v1.0.6] - 2026-04-08
|
||||
|
||||
### Features
|
||||
@@ -222,6 +676,29 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
|
||||
25
README.md
25
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -24,18 +24,23 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -131,11 +136,13 @@ lark-cli auth status
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
@@ -144,12 +151,14 @@ lark-cli auth status
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -196,7 +205,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
25
README.zh.md
25
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -24,18 +24,23 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -132,11 +137,13 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
@@ -145,12 +152,14 @@ lark-cli auth status
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
|
||||
## 认证
|
||||
|
||||
@@ -197,7 +206,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
109
cmd/api/api.go
109
cmd/api/api.go
@@ -41,6 +41,7 @@ type APIOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
}
|
||||
|
||||
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
|
||||
@@ -56,6 +57,10 @@ func normalisePath(raw string) string {
|
||||
|
||||
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
|
||||
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
return NewCmdApiWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
opts := &APIOptions{Factory: f}
|
||||
var asStr string
|
||||
|
||||
@@ -76,9 +81,9 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
|
||||
@@ -87,6 +92,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
@@ -94,10 +100,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -105,20 +108,25 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
|
||||
// buildAPIRequest validates flags and builds a RawApiRequest.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
params["page_size"] = opts.PageSize
|
||||
@@ -128,14 +136,53 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
Method: opts.Method,
|
||||
URL: normalisePath(opts.Path),
|
||||
Params: params,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload path: build formdata.
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
|
||||
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
@@ -153,7 +200,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
request, fileMeta, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,6 +211,9 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
@@ -190,12 +240,13 @@ func apiRun(opts *APIOptions) error {
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
|
||||
@@ -5,6 +5,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -179,6 +180,24 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("expected --as flag to be hidden in strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -706,3 +725,98 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.File != "image=photo.jpg" {
|
||||
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with --output")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutual exclusion error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with GET")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires POST") {
|
||||
t.Errorf("expected method error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file stdin with --data stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
|
||||
// Pass "auth" as a literal so the error message reads
|
||||
// `"auth" is not supported: ...`
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
|
||||
@@ -5,15 +5,19 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -303,3 +307,72 @@ func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credenti
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
|
||||
// simulating env/sidecar mode for guard tests.
|
||||
type stubExternalProvider struct{ name string }
|
||||
|
||||
func (s *stubExternalProvider) Name() string { return s.name }
|
||||
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
|
||||
// extension provider, simulating env/sidecar credential mode.
|
||||
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
stub := &stubExternalProvider{name: "env"}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
return f
|
||||
}
|
||||
|
||||
func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
f := newFactoryWithExternalProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"login", []string{"login"}},
|
||||
{"logout", []string{"logout"}},
|
||||
{"status", []string{"status"}},
|
||||
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
|
||||
{"list", []string{"list"}},
|
||||
{"scopes", []string{"scopes"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := NewCmdAuth(f)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
||||
matched, _, _ := cmd.Find(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
59
cmd/auth/list_test.go
Normal file
59
cmd/auth/list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
|
||||
// config exists yet — scripts and AI agents use it as an idempotent "do I
|
||||
// have any users?" check, so the exit code carries semantic weight. Pair
|
||||
// that with the existing "configured but no logged-in users" branch (also
|
||||
// exit 0) and both empty states are consistent.
|
||||
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
// Local workspace → hint must mention init, not bind.
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config init") {
|
||||
t.Errorf("local hint missing config init: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
// `config bind --help` instead of the local-only `config init`.
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config bind --help") {
|
||||
t.Errorf("agent hint must point at config bind --help: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config init") {
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -72,7 +71,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -134,18 +133,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
domainSet := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domainSet[p] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domainSet[sc.Service] = true
|
||||
}
|
||||
selectedDomains = make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
selectedDomains = append(selectedDomains, d)
|
||||
}
|
||||
sort.Strings(selectedDomains)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -254,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -262,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"agent_hint": msg.AgentTimeoutHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -271,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -357,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
@@ -451,6 +451,8 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
@@ -459,11 +461,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
// 2. Expand domains: include auth_domain children
|
||||
domainSet := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
domainSet[d] = true
|
||||
for _, child := range registry.GetAuthChildren(d) {
|
||||
domainSet[child] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
@@ -472,7 +479,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate and sort
|
||||
// 4. Deduplicate and sort
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
@@ -481,14 +488,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domains[p] = true
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
@@ -179,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
|
||||
|
||||
// Phase 2: confirmation
|
||||
var confirmed bool
|
||||
form2 := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(msg.ConfirmAuth).
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form2.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
|
||||
return &interactiveResult{
|
||||
Domains: selectedDomains,
|
||||
ScopeLevel: permLevel,
|
||||
|
||||
@@ -22,13 +22,14 @@ type loginMsg struct {
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AgentTimeoutHint string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
@@ -58,13 +59,14 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
@@ -93,13 +95,14 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
@@ -122,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -69,6 +70,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
@@ -88,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func emptyIfNil(s []string) []string {
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
@@ -136,7 +136,6 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
@@ -170,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
@@ -190,20 +189,17 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
@@ -363,7 +364,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -371,16 +372,19 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -392,15 +396,18 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -408,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -469,13 +480,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -490,10 +501,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -508,9 +519,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -614,15 +625,18 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -634,6 +648,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +763,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,16 +791,18 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Not granted scopes:") {
|
||||
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
@@ -903,3 +925,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
if !domains["docs"] {
|
||||
t.Error("docs should still be a known auth domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if strings.HasPrefix(s, "board:whiteboard:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
cmd/build.go
Normal file
133
cmd/build.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// BuildOption configures optional aspects of the command tree construction.
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
// Terminal detection is delegated to cmdutil.NewIOStreams.
|
||||
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.streams = cmdutil.NewIOStreams(in, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
|
||||
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.keychain = kc
|
||||
}
|
||||
}
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
// HideProfile(isSingleAppMode()).
|
||||
func HideProfile(hide bool) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.globals.HideProfile = hide
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
for _, o := range opts {
|
||||
if o != nil {
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
// the same values the Factory ends up using.
|
||||
if cfg.streams == nil {
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
|
||||
rootCmd.SetContext(ctx)
|
||||
rootCmd.SetIn(cfg.streams.In)
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
}
|
||||
63
cmd/build_api_test.go
Normal file
63
cmd/build_api_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// noopKeychain is a zero-side-effect KeychainAccess for exercising
|
||||
// WithKeychain without touching the platform keychain.
|
||||
type noopKeychain struct{}
|
||||
|
||||
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// TestBuild_ExternalAPI asserts the library surface that external consumers
|
||||
// (e.g. cli-server) depend on: Build composes a root command from an
|
||||
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
|
||||
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
|
||||
func TestBuild_ExternalAPI(t *testing.T) {
|
||||
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
|
||||
SetDefaultFS(vfs.OsFs{})
|
||||
SetDefaultFS(nil)
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
rootCmd := Build(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(noopKeychain{}),
|
||||
HideProfile(true),
|
||||
)
|
||||
|
||||
if rootCmd == nil {
|
||||
t.Fatal("Build returned nil root command")
|
||||
}
|
||||
if rootCmd.Use != "lark-cli" {
|
||||
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
|
||||
}
|
||||
if len(rootCmd.Commands()) == 0 {
|
||||
t.Error("Build produced a root command with no subcommands")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_NoOptions guards against regression of the nil-streams panic:
|
||||
// calling Build without WithIO must fall back to SystemIO rather than
|
||||
// deref nil at rootCmd.SetIn/Out/Err.
|
||||
func TestBuild_NoOptions(t *testing.T) {
|
||||
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
|
||||
if rootCmd == nil {
|
||||
t.Fatal("Build returned nil root command")
|
||||
}
|
||||
if rootCmd.Use != "lark-cli" {
|
||||
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
|
||||
}
|
||||
}
|
||||
67
cmd/build_memstats_test.go
Normal file
67
cmd/build_memstats_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
|
||||
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
|
||||
// *cobra.Command instances into cobra's package-global flag-completion map.
|
||||
//
|
||||
// This guards the new default (completions disabled) — if someone flips the
|
||||
// zero-value back to "enabled", the per-Build memory growth observed under
|
||||
// `scripts/bench_build` would resurface in production hot paths that build
|
||||
// the root command without serving a completion request.
|
||||
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
|
||||
if cmdutil.FlagCompletionsEnabled() {
|
||||
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
|
||||
}
|
||||
|
||||
snap := func() (heapMB float64, objs uint64) {
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
|
||||
}
|
||||
|
||||
// Warm one-time caches (registry JSON decode, embed reads) so the first
|
||||
// Build's lazy allocations don't skew the per-iteration delta.
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
baseMB, baseObj := snap()
|
||||
|
||||
const N = 20
|
||||
for range N {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
mb, obj := snap()
|
||||
|
||||
deltaMB := mb - baseMB
|
||||
deltaObj := int64(obj) - int64(baseObj)
|
||||
perBuildKB := deltaMB * 1024 / float64(N)
|
||||
perBuildObj := deltaObj / int64(N)
|
||||
|
||||
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
|
||||
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
|
||||
|
||||
// With completions disabled (the default), per-Build retained growth
|
||||
// should be minimal. Threshold is conservative: the previously observed
|
||||
// leak with completions enabled was ~hundreds of KB and thousands of
|
||||
// objects per Build, well above this bound.
|
||||
const maxKBPerBuild = 50.0
|
||||
const maxObjsPerBuild = 500
|
||||
if perBuildKB > maxKBPerBuild {
|
||||
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
|
||||
}
|
||||
if perBuildObj > maxObjsPerBuild {
|
||||
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
|
||||
}
|
||||
}
|
||||
632
cmd/config/bind.go
Normal file
632
cmd/config/bind.go
Normal file
@@ -0,0 +1,632 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// BindOptions holds all inputs for config bind.
|
||||
type BindOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Source string
|
||||
AppID string
|
||||
// Identity selects one of two presets — "bot-only" or "user-default" —
|
||||
// that expand to underlying StrictMode + DefaultAs in applyPreferences.
|
||||
// Empty means "decide later": TUI prompts, flag mode defaults to bot-only
|
||||
// (the safer choice — bot acts under its own identity, no impersonation
|
||||
// risk; users can still opt into "user-default" via --identity).
|
||||
Identity string
|
||||
|
||||
// Force opts in to an otherwise-blocked flag-mode transition — currently
|
||||
// only the bot-only → user-default identity escalation. TUI mode ignores
|
||||
// this flag because its own prompts already require human confirmation.
|
||||
Force bool
|
||||
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
||||
// the account being bound. Populated after resolveAccount; TUI stages
|
||||
// that run before that (source / account selection) render brand-aware
|
||||
// text with an empty value, which brandDisplay falls back to Feishu.
|
||||
Brand string
|
||||
|
||||
// IsTUI is the resolved interactive-mode flag: true only when Source is
|
||||
// empty and stdin is a terminal. Computed once at the top of
|
||||
// configBindRun; downstream branches read this instead of rechecking
|
||||
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
|
||||
IsTUI bool
|
||||
}
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
|
||||
--identity bot-only bot only (safer default; no impersonation;
|
||||
cannot access user resources like personal
|
||||
calendar / mail / drive)
|
||||
--identity user-default user identity allowed (impersonates the user;
|
||||
needed for personal-resource access)
|
||||
|
||||
Default to bot-only if the user is unsure. Only run the command after
|
||||
the user confirms both intent and identity preset.
|
||||
|
||||
If lark-cli is already bound and the user only wants to change identity
|
||||
policy on the SAME app, use 'config strict-mode' — that's the policy
|
||||
switch and does not require re-bind. Use 'config bind' only when the
|
||||
underlying app itself changes.
|
||||
|
||||
Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
lark-cli config bind --source lark-channel
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return configBindRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// configBindRun is the top-level orchestrator. Each step delegates to a named
|
||||
// helper whose signature declares its contract; the body reads as the shape of
|
||||
// the bind flow itself, not its mechanics.
|
||||
func configBindRun(opts *BindOptions) error {
|
||||
if err := validateBindFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decide TUI-vs-flag mode exactly once; every downstream branch reads
|
||||
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
|
||||
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
|
||||
|
||||
source, err := finalizeSource(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
core.SetCurrentWorkspace(core.Workspace(source))
|
||||
targetConfigPath := core.GetConfigPath()
|
||||
|
||||
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Cancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
appConfig, err := resolveAccount(opts, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Brand = string(appConfig.Brand)
|
||||
|
||||
if err := resolveIdentity(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
|
||||
// existingBinding is the outcome of checking whether a workspace was already
|
||||
// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller
|
||||
// should pass it to commitBinding for stale-keychain cleanup after the new
|
||||
// config is durably written). Cancelled is true iff the user declined to
|
||||
// replace it in the TUI prompt; the caller should exit cleanly.
|
||||
type existingBinding struct {
|
||||
ConfigBytes []byte
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// finalizeSource returns the validated bind source, reconciling three inputs:
|
||||
// - opts.Source: the value of --source (may be empty)
|
||||
// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv
|
||||
// - TUI mode: can prompt the user if neither flag nor env yields a source
|
||||
//
|
||||
// Resolution (in order):
|
||||
// 1. If --source is a non-empty invalid value → fail with ErrValidation.
|
||||
// 2. If both --source and an env signal are present and disagree → fail
|
||||
// loud; the user almost certainly ran the command in the wrong context.
|
||||
// 3. TUI mode only: prompt for language first (so later prompts respect it).
|
||||
// 4. --source wins if set. Otherwise use the env-detected source. Otherwise
|
||||
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
|
||||
func finalizeSource(opts *BindOptions) (string, error) {
|
||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
|
||||
}
|
||||
|
||||
var detected string
|
||||
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case core.WorkspaceOpenClaw:
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
detected = "lark-channel"
|
||||
}
|
||||
|
||||
// Explicit and env detection must agree when both are present. Reject
|
||||
// before any interactive prompts — running inside Hermes with
|
||||
// --source openclaw (or vice versa) is almost always a mistake.
|
||||
if explicit != "" && detected != "" && explicit != detected {
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
||||
"remove --source to auto-detect, or run this command in the correct Agent context")
|
||||
}
|
||||
|
||||
// TUI: prompt for language before any downstream prompts. The source
|
||||
// selection itself may still be skipped entirely if --source or the
|
||||
// env already pinned it.
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection("")
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
opts.Lang = lang
|
||||
}
|
||||
|
||||
if explicit != "" {
|
||||
return explicit, nil
|
||||
}
|
||||
if detected != "" {
|
||||
return detected, nil
|
||||
}
|
||||
if opts.IsTUI {
|
||||
return tuiSelectSource(opts)
|
||||
}
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
|
||||
}
|
||||
|
||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||
// how to proceed. In TUI mode the user is prompted to keep or replace. In flag
|
||||
// mode the existing binding is silently overwritten — commitBinding will emit a
|
||||
// notice on success so the caller still sees that a rebind happened.
|
||||
// See existingBinding for the returned fields.
|
||||
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
|
||||
oldConfigData, _ := vfs.ReadFile(configPath)
|
||||
if oldConfigData == nil {
|
||||
return existingBinding{}, nil
|
||||
}
|
||||
|
||||
if opts.IsTUI {
|
||||
action, err := tuiConflictPrompt(opts, source, configPath)
|
||||
if err != nil {
|
||||
return existingBinding{}, err
|
||||
}
|
||||
if action == "cancel" {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||
return existingBinding{Cancelled: true}, nil
|
||||
}
|
||||
return existingBinding{ConfigBytes: oldConfigData}, nil
|
||||
}
|
||||
|
||||
return existingBinding{ConfigBytes: oldConfigData}, nil
|
||||
}
|
||||
|
||||
// resolveAccount runs the source-agnostic bind flow: construct the binder,
|
||||
// enumerate candidates, pick one via the shared decision layer, and build a
|
||||
// ready-to-persist AppConfig. Adding a new bind source only requires
|
||||
// implementing SourceBinder — none of the logic below needs to change.
|
||||
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
|
||||
binder, err := newBinder(source, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates, err := binder.ListCandidates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI,
|
||||
func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return binder.Build(picked.AppID)
|
||||
}
|
||||
|
||||
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
|
||||
// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer
|
||||
// preset (bot acts under its own identity, no impersonation). Users who
|
||||
// want the broader capability set can pass --identity user-default.
|
||||
func resolveIdentity(opts *BindOptions) error {
|
||||
if opts.Identity != "" {
|
||||
return nil
|
||||
}
|
||||
if opts.IsTUI {
|
||||
id, err := tuiSelectIdentity(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Identity = id
|
||||
return nil
|
||||
}
|
||||
opts.Identity = "bot-only"
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasStrictBotLock reports whether the given config bytes declare a
|
||||
// bot-only lock on at least one app. Unparseable input returns false — it
|
||||
// signals "no enforceable lock to honor", consistent with how the rest of
|
||||
// the bind flow treats a corrupt previous config (commitBinding will
|
||||
// overwrite it cleanly).
|
||||
func hasStrictBotLock(data []byte) bool {
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// warnIdentityEscalation surfaces the risk of a flag-mode bot-only →
|
||||
// user-default identity change. Without --force, the CLI refuses so an AI
|
||||
// Agent has to relay the warning to the user and get explicit opt-in before
|
||||
// retrying. TUI mode is exempt: tuiConflictPrompt + tuiSelectIdentity
|
||||
// already require human confirmation in-flow.
|
||||
func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error {
|
||||
if opts.IsTUI || opts.Force || previousConfigBytes == nil {
|
||||
return nil
|
||||
}
|
||||
if opts.Identity != "user-default" {
|
||||
return nil
|
||||
}
|
||||
if !hasStrictBotLock(previousConfigBytes) {
|
||||
return nil
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
// flag-mode bind that lands on user-default. The bot-only → user-default
|
||||
// escalation is already covered by warnIdentityEscalation (errors out before
|
||||
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
||||
// during identity selection — so this fires specifically for the case those
|
||||
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
||||
// no previous bot lock to escalate from. Without this, AI agents finish such
|
||||
// a bind with only a "配置成功" message and never relay to the user that the
|
||||
// AI can now act under their identity.
|
||||
func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
sm := core.StrictModeBot
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsBot
|
||||
case "user-default":
|
||||
sm := core.StrictModeOff
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
}
|
||||
if opts.Lang != "" {
|
||||
appConfig.Lang = opts.Lang
|
||||
}
|
||||
}
|
||||
|
||||
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
||||
// best-effort cleanup of stale keychain entries from the previous binding (if
|
||||
// any), and a JSON success envelope. Cleanup runs only after the new config
|
||||
// is durably written — if anything fails earlier, the old workspace stays
|
||||
// usable.
|
||||
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to create workspace directory: %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to marshal config: %v", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to write config %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
replaced := previousConfigBytes != nil
|
||||
msg := getBindMsg(opts.Lang)
|
||||
display := sourceDisplayName(source)
|
||||
|
||||
if replaced {
|
||||
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
||||
|
||||
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
||||
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||
// noise. Flag mode (Agent orchestration, scripts, piped output) still
|
||||
// gets the full envelope for programmatic consumption.
|
||||
if opts.IsTUI {
|
||||
return nil
|
||||
}
|
||||
|
||||
envelope := map[string]interface{}{
|
||||
"ok": true,
|
||||
"workspace": source,
|
||||
"app_id": appConfig.AppId,
|
||||
"config_path": configPath,
|
||||
"replaced": replaced,
|
||||
"identity": opts.Identity,
|
||||
}
|
||||
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupKeychainFromData removes keychain entries referenced by a previous
|
||||
// config snapshot, skipping any entry whose keychain ID is still in use by
|
||||
// the new app config. This prevents rebinding the same appId from deleting
|
||||
// the secret that ForStorage just wrote (old and new secret share the same
|
||||
// keychain key, derived from appId). Best-effort: errors are silently
|
||||
// ignored (same contract as config init's cleanup).
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return
|
||||
}
|
||||
keepID := ""
|
||||
if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" {
|
||||
keepID = keep.AppSecret.Ref.ID
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
|
||||
continue
|
||||
}
|
||||
core.RemoveSecretStore(app.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// TUI helpers (huh forms, matching config init interactive style)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// tuiSelectSource prompts user to choose bind source.
|
||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
detected := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
switch detected {
|
||||
case core.WorkspaceOpenClaw:
|
||||
source = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
source = "lark-channel"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
}
|
||||
|
||||
// Resolve actual paths for display
|
||||
openclawPath := resolveOpenClawConfigPath()
|
||||
hermesEnvPath := resolveHermesEnvPath()
|
||||
larkChannelPath := resolveLarkChannelConfigPath()
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectSource).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
|
||||
).
|
||||
Value(&source),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
options := make([]huh.Option[int], 0, len(candidates))
|
||||
for i, c := range candidates {
|
||||
label := c.AppID
|
||||
if c.Label != "" {
|
||||
label = fmt.Sprintf("%s (%s)", c.Label, c.AppID)
|
||||
}
|
||||
options = append(options, huh.NewOption(label, i))
|
||||
}
|
||||
|
||||
var selected int
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &candidates[selected], nil
|
||||
}
|
||||
|
||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
if data, err := vfs.ReadFile(configPath); err == nil {
|
||||
var multi core.MultiAppConfig
|
||||
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
|
||||
app := multi.Apps[0]
|
||||
existingSummary = fmt.Sprintf(msg.ConflictDesc,
|
||||
source, app.AppId, app.Brand, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
var action string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title(msg.ConflictTitle).
|
||||
Description(existingSummary),
|
||||
huh.NewSelect[string]().
|
||||
Options(
|
||||
huh.NewOption(msg.ConflictForce, "force"),
|
||||
huh.NewOption(msg.ConflictCancel, "cancel"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "cancel", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// indent prepends two spaces to every line of s. Used to visually nest
|
||||
// multi-line option descriptions under their label in tuiSelectIdentity.
|
||||
func indent(s string) string {
|
||||
return " " + strings.ReplaceAll(s, "\n", "\n ")
|
||||
}
|
||||
|
||||
// validateBindFlags validates enum flags early, before any side effects.
|
||||
func validateBindFlags(opts *BindOptions) error {
|
||||
if opts.Identity != "" {
|
||||
switch opts.Identity {
|
||||
case "bot-only", "user-default":
|
||||
default:
|
||||
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuiSelectIdentity prompts user to pick one of two identity presets.
|
||||
// bot-only is listed first so Enter on the default highlight maps to the
|
||||
// flag-mode default for consistency across the two modes, and also because
|
||||
// bot-only is the safer preset (no impersonation risk).
|
||||
//
|
||||
// Layout: each option's description is embedded under its label using a
|
||||
// multi-line option value. huh styles the whole option block (label +
|
||||
// indented description) as selected / unselected, giving a clear visual
|
||||
// mapping between picker rows and their explanations — the dynamic
|
||||
// DescriptionFunc approach breaks here because a longer description on
|
||||
// hover pushes options out of the field's initial viewport.
|
||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
brand := brandDisplay(opts.Brand, opts.Lang)
|
||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||
var value string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectIdentity).
|
||||
Options(
|
||||
huh.NewOption(botLabel, "bot-only"),
|
||||
huh.NewOption(userLabel, "user-default"),
|
||||
).
|
||||
Value(&value),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
175
cmd/config/bind_messages.go
Normal file
175
cmd/config/bind_messages.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
||||
//
|
||||
// Brand-aware strings use a %s slot where the UI-friendly product name
|
||||
// should appear; callers pass brandDisplay(brand, lang) at that position.
|
||||
// English templates use %[N]s positional indices when the natural English
|
||||
// order puts brand before source.
|
||||
type bindMsg struct {
|
||||
// Source selection.
|
||||
// SelectSourceDesc format: brand.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
SourceLarkChannel string // format: resolved config path.
|
||||
|
||||
// Account selection (OpenClaw multi-account).
|
||||
// Format: source display name ("OpenClaw" | "Hermes"), brand.
|
||||
SelectAccount string
|
||||
|
||||
// Conflict prompt.
|
||||
ConflictTitle string
|
||||
ConflictDesc string // format: workspace, appId, brand, configPath.
|
||||
ConflictForce string
|
||||
ConflictCancel string
|
||||
ConflictCancelled string
|
||||
|
||||
// Post-bind agent-friendly message emitted in the stdout JSON envelope's
|
||||
// "message" field. Written as imperative instructions to the agent reading
|
||||
// the JSON — not as description for a human reader.
|
||||
// MessageBotOnly format: app_id, source display name, brand.
|
||||
// MessageUserDefault format: app_id, source display name, source display
|
||||
// name (second source ref anchors the "run in this chat" directive).
|
||||
// MessageUserDefault directs the Agent at the blocking single-call
|
||||
// `auth login --recommend` flow: the CLI streams verification_url to
|
||||
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
|
||||
// real time, then blocks until the user authorizes in their own browser.
|
||||
// The Agent also needs an explicit "do not navigate the URL yourself"
|
||||
// guard — its own browser is sandboxed and cannot complete the user's
|
||||
// authorization.
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
|
||||
// Identity preset (collapses strict-mode + default-as into one choice).
|
||||
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
|
||||
// the huh Select options. IdentityBotOnlyDesc / IdentityUserDefaultDesc
|
||||
// carry the longer explanation for each choice; tuiSelectIdentity
|
||||
// embeds the description under its label as a multi-line option value,
|
||||
// so huh renders the whole "label + indented description" block as one
|
||||
// picker row and styles it selected / unselected as a unit. Dynamic
|
||||
// DescriptionFunc was tried first but breaks here: a longer description
|
||||
// on hover pushes the field's initial viewport, clipping the selected
|
||||
// option row on terminals that fit the smaller description.
|
||||
// IdentityBotOnlyDesc format: brand.
|
||||
// IdentityUserDefaultDesc format: brand, brand.
|
||||
SelectIdentity string
|
||||
IdentityBotOnly string
|
||||
IdentityUserDefault string
|
||||
IdentityBotOnlyDesc string
|
||||
IdentityUserDefaultDesc string
|
||||
|
||||
// Post-bind success notice printed to stderr once the workspace config
|
||||
// has been durably written. Rendered as two parts joined with "\n":
|
||||
// BindSuccessHeader — format: source display name.
|
||||
// BindSuccessNotice — caveat about one-time sync.
|
||||
// We intentionally do NOT emit a "replaced" suffix here (the TUI already
|
||||
// asked the user to confirm overwrite; flag mode carries `replaced:true`
|
||||
// in the stdout JSON envelope), and we do NOT emit an inline "next step"
|
||||
// line for user-default (stderr is the human channel; agents read the
|
||||
// MessageUserDefault field in the JSON envelope).
|
||||
BindSuccessHeader string
|
||||
BindSuccessNotice string
|
||||
|
||||
// IdentityEscalationMessage / IdentityEscalationHint are returned when a
|
||||
// previous bind set the workspace to bot-only and a flag-mode (AI-driven)
|
||||
// caller tries to rebind with --identity user-default without --force.
|
||||
// The error asks the Agent to surface the risk to the user and re-run
|
||||
// with --force only after explicit user confirmation. TUI mode does not
|
||||
// hit this code path — tuiConflictPrompt + tuiSelectIdentity already
|
||||
// require in-flow human confirmation.
|
||||
IdentityEscalationMessage string
|
||||
IdentityEscalationHint string
|
||||
}
|
||||
|
||||
var bindMsgZh = &bindMsg{
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
SourceLarkChannel: "Lark Channel — 配置文件: %s",
|
||||
|
||||
SelectAccount: "检测到 %s 中已配置多个%s应用,请选择一个",
|
||||
|
||||
ConflictTitle: "检测到已有配置",
|
||||
ConflictDesc: "%q 已配置 lark-cli:\n App ID: %s\n 品牌: %s\n 配置文件: %s",
|
||||
ConflictForce: "修改配置",
|
||||
ConflictCancel: "保留当前配置",
|
||||
ConflictCancelled: "已保留当前配置",
|
||||
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
|
||||
SelectIdentity: "你希望 AI 如何与你协作?",
|
||||
IdentityBotOnly: "以机器人身份",
|
||||
IdentityUserDefault: "以你的身份",
|
||||
IdentityBotOnlyDesc: "AI 将在%s中以机器人的身份执行所有操作,适合作为团队助手,用于多人协作场景,如群聊问答、团队通知、公共文档维护。",
|
||||
IdentityUserDefaultDesc: "AI 将在%s中以你的名义执行所有操作,如读写文档、搜索消息、修改日程等,建议仅限个人使用。\n" +
|
||||
"⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的%s数据。",
|
||||
|
||||
BindSuccessHeader: "配置成功!lark-cli 已可在 %s 中使用。",
|
||||
BindSuccessNotice: "注意:这是一次性同步,后续 Agent 配置变更不会自动更新到 lark-cli。如需重新同步,请执行 `lark-cli config bind`",
|
||||
|
||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||
}
|
||||
|
||||
var bindMsgEn = &bindMsg{
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
SourceLarkChannel: "Lark Channel — config: %s",
|
||||
|
||||
// Args order (source, brand) matches the Chinese template; %[N]s lets the
|
||||
// English reading order differ while the caller passes args in one order.
|
||||
SelectAccount: "Multiple %[2]s apps configured in %[1]s — select one to continue.",
|
||||
|
||||
ConflictTitle: "Existing configuration found",
|
||||
ConflictDesc: "lark-cli is already set up for %q:\n App ID: %s\n Brand: %s\n Config: %s",
|
||||
ConflictForce: "Update config",
|
||||
ConflictCancel: "Keep current config",
|
||||
ConflictCancelled: "Current config kept. No changes made.",
|
||||
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
|
||||
SelectIdentity: "How should the AI work with you?",
|
||||
IdentityBotOnly: "As bot",
|
||||
IdentityUserDefault: "As you",
|
||||
IdentityBotOnlyDesc: "Works under its own identity in %s. Best for group chats, team notifications, and shared documents.",
|
||||
IdentityUserDefaultDesc: "Works under your identity in %s, managing docs, messages, calendar, and more on your behalf. Personal use only.\n" +
|
||||
"⚠️ Don't share this bot with others or add it to group chats. It has access to your personal %s data.",
|
||||
|
||||
BindSuccessHeader: "All set! lark-cli is now ready to use in %s.",
|
||||
BindSuccessNotice: "Note: This is a one-time sync. To re-sync future changes, run `lark-cli config bind`",
|
||||
|
||||
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
|
||||
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
||||
}
|
||||
|
||||
func getBindMsg(lang string) *bindMsg {
|
||||
if lang == "en" {
|
||||
return bindMsgEn
|
||||
}
|
||||
return bindMsgZh
|
||||
}
|
||||
|
||||
// brandDisplay returns the UI-friendly product name for the given brand
|
||||
// identifier and display language. "lark" maps to "Lark" in both zh and en.
|
||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
||||
// this is the safe default when the brand hasn't been resolved yet (for
|
||||
// example, on the pre-binding source-selection screen).
|
||||
func brandDisplay(brand, lang string) string {
|
||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||
return "Lark"
|
||||
}
|
||||
if lang == "en" {
|
||||
return "Feishu"
|
||||
}
|
||||
return "飞书"
|
||||
}
|
||||
1605
cmd/config/bind_test.go
Normal file
1605
cmd/config/bind_test.go
Normal file
File diff suppressed because it is too large
Load Diff
62
cmd/config/bind_warning_test.go
Normal file
62
cmd/config/bind_warning_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
// with the given identity preset in flag (non-TUI) mode, and returns captured
|
||||
// stderr. Hermes is the simplest source to fake (single .env file).
|
||||
func runHermesBindWithIdentity(t *testing.T, identity string) string {
|
||||
t.Helper()
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: identity,
|
||||
Lang: "zh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bind failed: %v", err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
|
||||
// gap that previously slipped through: a fresh flag-mode bind landing on
|
||||
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
|
||||
// and IdentityUserDefaultDesc only renders in TUI selection — so without
|
||||
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
|
||||
// first-time user-default bind.
|
||||
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "user-default")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "bot-only")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
489
cmd/config/binder.go
Normal file
489
cmd/config/binder.go
Normal file
@@ -0,0 +1,489 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Candidate is the source-agnostic view of a bindable account.
|
||||
// It carries only the identity fields needed by selectCandidate / TUI;
|
||||
// secrets remain inside the SourceBinder implementation.
|
||||
type Candidate struct {
|
||||
AppID string
|
||||
Label string
|
||||
}
|
||||
|
||||
// SourceBinder abstracts a bind source (openclaw / hermes / future sources).
|
||||
// Implementations only list candidates and build an AppConfig for a chosen
|
||||
// candidate — they stay out of mode (TUI vs flag) and orchestration concerns.
|
||||
type SourceBinder interface {
|
||||
// Name returns the source identifier (used in error envelopes).
|
||||
Name() string
|
||||
// ConfigPath returns the resolved path to the source's config file.
|
||||
ConfigPath() string
|
||||
// ListCandidates enumerates bindable accounts from the source config.
|
||||
// An empty slice is valid (selectCandidate will turn it into a typed error).
|
||||
ListCandidates() ([]Candidate, error)
|
||||
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
|
||||
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
|
||||
Build(appID string) (*core.AppConfig, error)
|
||||
}
|
||||
|
||||
// newBinder constructs the SourceBinder for the given source name.
|
||||
func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
||||
switch source {
|
||||
case "openclaw":
|
||||
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
|
||||
case "hermes":
|
||||
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
|
||||
case "lark-channel":
|
||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||
default:
|
||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||
}
|
||||
}
|
||||
|
||||
// selectCandidate is the single source of truth for account-selection logic.
|
||||
// Every bind source funnels through this function, so the "how many
|
||||
// candidates × was --app-id given × is this TUI" policy is defined once.
|
||||
//
|
||||
// Decision matrix:
|
||||
//
|
||||
// candidates=0 → error "no app configured"
|
||||
// appID set, match → selected
|
||||
// appID set, no match → error + candidate list
|
||||
// candidates=1, appID="" → auto-select
|
||||
// candidates≥2, appID="", isTUI=true → tuiPrompt
|
||||
// candidates≥2, appID="", isTUI=false → error + candidate list
|
||||
//
|
||||
// The last branch is the one that matters for flag-mode callers: an explicit
|
||||
// --source must never silently drop into an interactive prompt just because
|
||||
// stdin happens to be a terminal.
|
||||
func selectCandidate(
|
||||
binder SourceBinder,
|
||||
candidates []Candidate,
|
||||
appIDFlag string,
|
||||
isTUI bool,
|
||||
tuiPrompt func([]Candidate) (*Candidate, error),
|
||||
) (*Candidate, error) {
|
||||
src := binder.Name()
|
||||
cfgBase := filepath.Base(binder.ConfigPath())
|
||||
|
||||
if len(candidates) == 0 {
|
||||
// Reader succeeded but yielded nothing — e.g. every openclaw account
|
||||
// is disabled. Missing-file / missing-field cases return typed errors
|
||||
// from ListCandidates itself and never reach here.
|
||||
switch src {
|
||||
case "openclaw":
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
"no Feishu app configured in openclaw.json",
|
||||
"configure channels.feishu.appId in openclaw.json")
|
||||
default:
|
||||
return nil, output.ErrValidation("%s: no app configured", src)
|
||||
}
|
||||
}
|
||||
|
||||
if appIDFlag != "" {
|
||||
for i := range candidates {
|
||||
if candidates[i].AppID == appIDFlag {
|
||||
return &candidates[i], nil
|
||||
}
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
return &candidates[0], nil
|
||||
}
|
||||
|
||||
if isTUI {
|
||||
return tuiPrompt(candidates)
|
||||
}
|
||||
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||
func formatCandidates(candidates []Candidate) string {
|
||||
ids := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
label := c.AppID
|
||||
if c.Label != "" {
|
||||
label = fmt.Sprintf("%s (%s)", c.AppID, c.Label)
|
||||
}
|
||||
ids = append(ids, label)
|
||||
}
|
||||
return strings.Join(ids, "\n ")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// openclawBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type openclawBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read / re-parse.
|
||||
cfg *binding.OpenClawRoot
|
||||
rawApps []binding.CandidateApp
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Name() string { return "openclaw" }
|
||||
func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify OpenClaw is installed and configured")
|
||||
}
|
||||
if cfg.Channels.Feishu == nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
"openclaw.json missing channels.feishu section",
|
||||
"configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
b.cfg = cfg
|
||||
b.rawApps = raw
|
||||
|
||||
result := make([]Candidate, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
result = append(result, Candidate{AppID: c.AppID, Label: c.Label})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *binding.CandidateApp
|
||||
for i := range b.rawApps {
|
||||
if b.rawApps[i].AppID == appID {
|
||||
selected = &b.rawApps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: appID %q not in candidates", appID)
|
||||
}
|
||||
|
||||
if selected.AppSecret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
||||
"configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// hermesBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type hermesBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
envMap map[string]string // cached between ListCandidates and Build
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Name() string { return "hermes" }
|
||||
func (b *hermesBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
envMap, err := readDotenv(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
||||
}
|
||||
appID := envMap["FEISHU_APP_ID"]
|
||||
if appID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
b.envMap = envMap
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: appID %q does not match env", appID)
|
||||
}
|
||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||
if appSecret == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// larkChannelBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type larkChannelBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read the file.
|
||||
cfg *binding.LarkChannelRoot
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Name() string { return "lark-channel" }
|
||||
func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify lark-channel-bridge is installed and configured")
|
||||
}
|
||||
if cfg.Accounts.App.ID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
b.cfg = cfg
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.cfg.Accounts.App.ID != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"keychain unavailable: %v", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
|
||||
// Moved here from bind.go so bind.go can focus on orchestration.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// sourceDisplayName returns the user-facing label for a source identifier,
|
||||
// matching the casing used in bind_messages.go (OpenClaw / Hermes).
|
||||
func sourceDisplayName(source string) string {
|
||||
switch source {
|
||||
case "openclaw":
|
||||
return "OpenClaw"
|
||||
case "hermes":
|
||||
return "Hermes"
|
||||
case "lark-channel":
|
||||
return "Lark Channel"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
// Note: HERMES_HOME is typically unset when users run bind from a regular
|
||||
// terminal. When AI agents execute bind within a Hermes subprocess, HERMES_HOME
|
||||
// may be set and should be respected.
|
||||
func resolveHermesEnvPath() string {
|
||||
hermesHome := os.Getenv("HERMES_HOME")
|
||||
if hermesHome == "" {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
hermesHome = filepath.Join(home, ".hermes")
|
||||
}
|
||||
return filepath.Join(hermesHome, ".env")
|
||||
}
|
||||
|
||||
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
||||
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
|
||||
// ~/.lark-channel/config.json with no env override — multi-instance is not
|
||||
// a supported scenario today.
|
||||
func resolveLarkChannelConfigPath() string {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
return filepath.Join(home, ".lark-channel", "config.json")
|
||||
}
|
||||
|
||||
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
|
||||
// chain as OpenClaw's src/config/paths.ts:
|
||||
// 1. OPENCLAW_CONFIG_PATH env → exact file path
|
||||
// 2. OPENCLAW_STATE_DIR env → <dir>/openclaw.json
|
||||
// 3. OPENCLAW_HOME env → <home>/.openclaw/openclaw.json
|
||||
// 4. ~/.openclaw/openclaw.json (default)
|
||||
// 5. Legacy: ~/.clawdbot/clawdbot.json, ~/.openclaw/clawdbot.json
|
||||
func resolveOpenClawConfigPath() string {
|
||||
if p := os.Getenv("OPENCLAW_CONFIG_PATH"); p != "" {
|
||||
return expandHome(p)
|
||||
}
|
||||
|
||||
if stateDir := os.Getenv("OPENCLAW_STATE_DIR"); stateDir != "" {
|
||||
dir := expandHome(stateDir)
|
||||
return findConfigInDir(dir)
|
||||
}
|
||||
|
||||
home := os.Getenv("OPENCLAW_HOME")
|
||||
if home == "" {
|
||||
h, err := vfs.UserHomeDir()
|
||||
if err != nil || h == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
home = h
|
||||
} else {
|
||||
home = expandHome(home)
|
||||
}
|
||||
|
||||
newDir := filepath.Join(home, ".openclaw")
|
||||
if configFile := findConfigInDir(newDir); fileExists(configFile) {
|
||||
return configFile
|
||||
}
|
||||
|
||||
legacyDir := filepath.Join(home, ".clawdbot")
|
||||
if configFile := findConfigInDir(legacyDir); fileExists(configFile) {
|
||||
return configFile
|
||||
}
|
||||
|
||||
return filepath.Join(newDir, "openclaw.json")
|
||||
}
|
||||
|
||||
func findConfigInDir(dir string) string {
|
||||
primary := filepath.Join(dir, "openclaw.json")
|
||||
if fileExists(primary) {
|
||||
return primary
|
||||
}
|
||||
legacy := filepath.Join(dir, "clawdbot.json")
|
||||
if fileExists(legacy) {
|
||||
return legacy
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := vfs.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if strings.HasPrefix(path, "~/") || path == "~" {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[1:])
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// readDotenv reads a KEY=VALUE .env file. Comments (#) and blank lines skipped.
|
||||
// Matches Hermes's load_env() in hermes_cli/config.py.
|
||||
func readDotenv(path string) (map[string]string, error) {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
175
cmd/config/binder_test.go
Normal file
175
cmd/config/binder_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// fakeBinder is a test double for SourceBinder. selectCandidate only touches
|
||||
// Name and ConfigPath (for error messages); ListCandidates/Build are not called
|
||||
// from selectCandidate, so we can leave them as no-ops.
|
||||
type fakeBinder struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
|
||||
|
||||
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
|
||||
// guardrail that proves the non-TUI decision paths really do stay out of the
|
||||
// interactive prompt — otherwise a green test could still hide a silent TUI.
|
||||
func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) {
|
||||
t.Helper()
|
||||
return func([]Candidate) (*Candidate, error) {
|
||||
t.Fatal("tuiPrompt must not be called in flag mode")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// assertCandidate compares the full Candidate struct via DeepEqual so that
|
||||
// any future field added to Candidate is covered automatically.
|
||||
func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Candidate")
|
||||
}
|
||||
if !reflect.DeepEqual(*got, want) {
|
||||
t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// Locks in the generic fallback so that any future source added to
|
||||
// newBinder gets a well-formed validation error on "zero candidates"
|
||||
// even before it has a bespoke error message.
|
||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{{AppID: "cli_only", Label: "default"}}
|
||||
got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
// Flag-mode with multiple candidates and no --app-id must produce a
|
||||
// validation error and the candidate list, never an interactive prompt.
|
||||
// isTUI is the single gate; a real terminal alone must not trigger TUI.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
var gotCandidates []Candidate
|
||||
got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) {
|
||||
gotCandidates = cs
|
||||
return &cs[1], nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Whole-slice DeepEqual so additions to Candidate propagate to this check.
|
||||
if !reflect.DeepEqual(gotCandidates, candidates) {
|
||||
t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
// Even with only one candidate, a wrong --app-id must error rather than
|
||||
// silently auto-selecting. An explicit mismatch is always a user mistake,
|
||||
// not a reason to override their intent.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{{AppID: "cli_only"}}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
||||
// An explicit --app-id short-circuits the prompt even in TUI mode: a
|
||||
// flag the user typed should never be second-guessed by an interactive
|
||||
// prompt asking the same question.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_a"},
|
||||
{AppID: "cli_b"},
|
||||
}
|
||||
got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
||||
}
|
||||
@@ -14,10 +14,19 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Global CLI configuration management",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
|
||||
@@ -6,13 +6,16 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -35,6 +38,7 @@ func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
|
||||
@@ -87,15 +91,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,7 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
@@ -154,6 +159,7 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangDefault(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
@@ -340,3 +346,68 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
||||
type stubConfigExtProvider struct{ name string }
|
||||
|
||||
func (s *stubConfigExtProvider) Name() string { return s.name }
|
||||
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
stub := &stubConfigExtProvider{name: "env"}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
return f
|
||||
}
|
||||
|
||||
func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
{"show", []string{"show"}},
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
||||
matched, _, _ := cmd.Find(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.`,
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -63,10 +79,33 @@ verification URL from its output.`,
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
// Running init here would create a parallel app under the agent's workspace
|
||||
// dir, breaking the binding the user actually wants. --force-init lets a
|
||||
// human user override when they really do want a separate app.
|
||||
func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
@@ -269,7 +308,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
67
cmd/config/init_guard_test.go
Normal file
67
cmd/config/init_guard_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
clearAgentEnv(t)
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "--force-init") {
|
||||
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
// --force-init must let the user proceed even inside an Agent context.
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
|
||||
t.Errorf("--force-init should bypass the guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
|
||||
// Show QR code in terminal
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
// Branch on TTY: human-friendly copy in interactive terminals,
|
||||
// preserve original copy for AI / non-interactive callers.
|
||||
if f.IOStreams.IsTerminal {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
} else {
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
|
||||
// Step 3: Poll for result
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
|
||||
@@ -10,45 +10,56 @@ import (
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
ScanOrOpenLink string
|
||||
WaitingForScan string
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
WaitingForScan string // active polling indicator
|
||||
// Non-TTY (AI / non-interactive) variants — preserve original copy
|
||||
OpenLinkNonTTY string // primary link prompt
|
||||
WaitingForScanNonTTY string // passive waiting indicator
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScan: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
WaitingForScan: "正在获取你的应用配置结果...",
|
||||
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScanNonTTY: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScan: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
WaitingForScan: "Fetching configuration results...",
|
||||
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScanNonTTY: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
|
||||
@@ -48,17 +48,20 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
|
||||
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
t.Helper()
|
||||
fields := map[string]string{
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
|
||||
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
|
||||
@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -64,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
|
||||
@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
Long: `View or set strict mode — the identity restriction policy.
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
bot only bot identity allowed (user commands hidden)
|
||||
user only user identity allowed (bot commands hidden)
|
||||
off no restriction (default)
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
No args: show current mode. Switching does NOT require re-bind.
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
For AI agents: this is a security policy. DO NOT switch without
|
||||
explicit user confirmation — never run on your own initiative.`,
|
||||
Example: ` lark-cli config strict-mode # show current
|
||||
lark-cli config strict-mode user # switch (after user confirms)
|
||||
lark-cli config strict-mode bot --global # set globally
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
// only when the policy actually expands user-identity at that scope.
|
||||
// --global → compare raw multi.StrictMode (profiles with explicit
|
||||
// overrides are unaffected; their warning comes from the existing
|
||||
// "profile %q has strict-mode explicitly set" notice below).
|
||||
// profile → compare effective mode (override > global > default), so
|
||||
// a profile flipping from inherited bot to explicit off still warns.
|
||||
// The previous version always used the profile's effective mode, which
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
oldMode, _ = resolveStrictModeStatus(multi, app)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
|
||||
140
cmd/config/strict_mode_warning_test.go
Normal file
140
cmd/config/strict_mode_warning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
// returns the captured stderr — that's where success-path messages and the
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("strict-mode %v failed: %v", args, err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// expandsUserIdentity covers the only two transitions where AI gains the
|
||||
// ability to act under the user's identity, and asserts the warning fires.
|
||||
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
|
||||
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
|
||||
// relax) stay phrased identically.
|
||||
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "user")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "off")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
|
||||
// scope — those should stay quiet, otherwise AI will spam users with risk
|
||||
// text on every restrictive change.
|
||||
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "user")
|
||||
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
// Default starts at off; explicitly set bot — narrowing.
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
|
||||
// Off already permits user-identity, so off→user is not a NEW grant
|
||||
// even though it forces user identity. Don't warn.
|
||||
setupStrictModeTestConfig(t)
|
||||
out := runStrictMode(t, "user")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- --global path: comparison must use multi.StrictMode, not profile's
|
||||
// effective mode. The previous (buggy) version used resolveStrictModeStatus
|
||||
// here too, leading to both false positives (current profile has explicit
|
||||
// override unaffected by --global → still warned) and false negatives
|
||||
// (current profile has explicit override that masks an actual bot → off
|
||||
// global broadening for OTHER inheriting profiles → didn't warn).
|
||||
|
||||
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalsePositive: current profile has explicit "bot" override, global goes
|
||||
// off → user. The current profile is unaffected (still bot via override),
|
||||
// and off→user at the global level is not a new grant either. Must not warn.
|
||||
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot") // profile-level explicit bot
|
||||
runStrictMode(t, "off", "--global") // global = off
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalseNegative: global = bot, current profile has explicit "off" override.
|
||||
// Running --global off broadens OTHER inheriting profiles (bot → off). The
|
||||
// current profile doesn't change effective mode, but the policy still expanded
|
||||
// user-identity, so warning must fire. The pre-fix logic compared via the
|
||||
// current profile's effective mode and missed this case.
|
||||
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global") // global = bot
|
||||
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
|
||||
}
|
||||
}
|
||||
203
cmd/diagnose_scope_test.go
Normal file
203
cmd/diagnose_scope_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────
|
||||
|
||||
type diagMethodEntry struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"` // "api" or "shortcut"
|
||||
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
|
||||
Scope string `json:"scope"` // minimum-privilege scope
|
||||
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
|
||||
}
|
||||
|
||||
type diagScopeInfo struct {
|
||||
Scope string `json:"scope"`
|
||||
Recommend bool `json:"recommend"`
|
||||
InPriority bool `json:"in_priority"`
|
||||
}
|
||||
|
||||
type diagOutput struct {
|
||||
Methods []diagMethodEntry `json:"methods"`
|
||||
Scopes []diagScopeInfo `json:"scopes"`
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────
|
||||
|
||||
// diagAllKnownDomains returns sorted, deduplicated domain names from both
|
||||
// from_meta projects and shortcuts.
|
||||
func diagAllKnownDomains() []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for d := range seen {
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// methodKey uniquely identifies a method+scope pair for merging identities.
|
||||
type methodKey struct {
|
||||
domain string
|
||||
typ string
|
||||
method string
|
||||
scope string
|
||||
}
|
||||
|
||||
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
|
||||
func diagBuild(domains []string) diagOutput {
|
||||
recommend := registry.LoadAutoApproveSet()
|
||||
identities := []string{"user", "bot"}
|
||||
|
||||
merged := make(map[methodKey]*diagMethodEntry)
|
||||
allSC := shortcuts.AllShortcuts()
|
||||
|
||||
for _, domain := range domains {
|
||||
for _, identity := range identities {
|
||||
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
|
||||
for _, scope := range ce.Scopes {
|
||||
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
|
||||
k := methodKey{domain, "api", method, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "api",
|
||||
Method: method,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]diagMethodEntry, 0, len(merged))
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, e := range merged {
|
||||
methods = append(methods, *e)
|
||||
scopeSet[e.Scope] = true
|
||||
}
|
||||
sort.Slice(methods, func(i, j int) bool {
|
||||
if methods[i].Domain != methods[j].Domain {
|
||||
return methods[i].Domain < methods[j].Domain
|
||||
}
|
||||
if methods[i].Type != methods[j].Type {
|
||||
return methods[i].Type < methods[j].Type
|
||||
}
|
||||
if methods[i].Method != methods[j].Method {
|
||||
return methods[i].Method < methods[j].Method
|
||||
}
|
||||
return methods[i].Scope < methods[j].Scope
|
||||
})
|
||||
|
||||
scopeList := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
scopeList = append(scopeList, s)
|
||||
}
|
||||
sort.Strings(scopeList)
|
||||
|
||||
priorities := registry.LoadScopePriorities()
|
||||
scopes := make([]diagScopeInfo, len(scopeList))
|
||||
for i, s := range scopeList {
|
||||
_, inPri := priorities[s]
|
||||
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
|
||||
}
|
||||
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniq(ss []string, s string) []string {
|
||||
for _, existing := range ss {
|
||||
if existing == s {
|
||||
return ss
|
||||
}
|
||||
}
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
|
||||
func TestScopeSnapshot(t *testing.T) {
|
||||
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
result := diagBuild(diagAllKnownDomains())
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "snapshot.json")
|
||||
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
// detected but lark-cli is not bound to it" → bind --help) over
|
||||
// the OS-level "open ... no such file or directory".
|
||||
// For other errors (parse, perms), keep the raw error so the
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("config_file", msg, hint))
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("config_file", "config.json found"))
|
||||
@@ -238,7 +252,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: npm update -g @larksuite/cli")}
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
@@ -253,8 +267,9 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"checks": checks,
|
||||
"ok": allOK,
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !allOK {
|
||||
|
||||
175
cmd/error_auth_hint.go
Normal file
175
cmd/error_auth_hint.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// enrichMissingScopeError preserves the original need_user_authorization
|
||||
// message and appends a scope hint when the current command declares the
|
||||
// required scopes locally.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
exitErr.Detail.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.CurrentCommand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
|
||||
return scopes
|
||||
}
|
||||
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
|
||||
}
|
||||
|
||||
// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
|
||||
// shortcut command for the given identity.
|
||||
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.ScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), scopes...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return declaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
||||
// resolves the single recommended scope from the method's scopes list.
|
||||
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||
return interfaceStrings(requiredRaw)
|
||||
}
|
||||
|
||||
rawScopes, _ := method["scopes"].([]interface{})
|
||||
if len(rawScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
||||
if recommended == "" {
|
||||
for _, raw := range rawScopes {
|
||||
if scope, ok := raw.(string); ok && scope != "" {
|
||||
recommended = scope
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if recommended == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{recommended}
|
||||
}
|
||||
|
||||
// interfaceStrings converts a []interface{} containing strings into a compact
|
||||
// []string, skipping empty or non-string values.
|
||||
func interfaceStrings(values []interface{}) []string {
|
||||
scopes := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
scope, ok := value.(string)
|
||||
if !ok || scope == "" {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
cmd/event/appmeta_err.go
Normal file
25
cmd/event/appmeta_err.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
func describeAppMetaErr(err error) string {
|
||||
msg := err.Error()
|
||||
if url := authURLPattern.FindString(msg); url != "" {
|
||||
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
|
||||
}
|
||||
const maxErrLen = 200
|
||||
if len(msg) > maxErrLen {
|
||||
return msg[:maxErrLen] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
54
cmd/event/appmeta_err_test.go
Normal file
54
cmd/event/appmeta_err_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
|
||||
|
||||
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New(realisticPermError))
|
||||
if len(got) > 400 {
|
||||
t.Errorf("summary too long (%d chars): %q", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "scope") {
|
||||
t.Errorf("summary should mention scope requirement, got: %q", got)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
|
||||
if !strings.Contains(got, wantURL) {
|
||||
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
|
||||
}
|
||||
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
|
||||
if strings.Contains(got, noise) {
|
||||
t.Errorf("summary leaked noise %q: %q", noise, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
|
||||
long := strings.Repeat("x", 500)
|
||||
got := describeAppMetaErr(errors.New(long))
|
||||
if len(got) > 220 {
|
||||
t.Errorf("unknown error not truncated, len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New("network unreachable"))
|
||||
if got != "network unreachable" {
|
||||
t.Errorf("short err should pass through unchanged, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
|
||||
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
|
||||
got := describeAppMetaErr(errors.New(msg))
|
||||
if !strings.Contains(got, "open.larksuite.com") {
|
||||
t.Errorf("want larksuite URL extracted, got: %q", got)
|
||||
}
|
||||
}
|
||||
69
cmd/event/bus.go
Normal file
69
cmd/event/bus.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
var domain string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "_bus",
|
||||
Short: "Internal event bus daemon (do not call directly)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
|
||||
return cmd
|
||||
}
|
||||
24
cmd/event/console_url.go
Normal file
24
cmd/event/console_url.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
36
cmd/event/console_url_test.go
Normal file
36
cmd/event/console_url_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
371
cmd/event/consume.go
Normal file
371
cmd/event/consume.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type consumeCmdOpts struct {
|
||||
params []string
|
||||
jqExpr string
|
||||
quiet bool
|
||||
outputDir string
|
||||
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
|
||||
var o consumeCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume <EventKey>",
|
||||
Short: "Start consuming events for an EventKey",
|
||||
Long: `Start consuming real-time events for the given EventKey.
|
||||
|
||||
The consume command connects to the event bus daemon (starting it if needed),
|
||||
subscribes to the specified EventKey, and streams processed events to stdout.
|
||||
|
||||
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
|
||||
pretty-printed formatting.
|
||||
|
||||
Use 'event list' to see all available EventKeys.
|
||||
Use 'event schema <EventKey>' for parameter details.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConsume(cmd, f, args[0], o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
|
||||
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
|
||||
ignoreBrokenPipe()
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramMap, err := parseParams(o.params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyDef, ok := eventlib.Lookup(eventKey)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(eventKey)
|
||||
}
|
||||
|
||||
identity, err := resolveIdentity(cmd, f, keyDef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := o.outputDir
|
||||
if outputDir != "" {
|
||||
safePath, err := sanitizeOutputDir(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
preflightErrOut = io.Discard
|
||||
}
|
||||
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
|
||||
switch {
|
||||
case appVerErr != nil:
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
|
||||
case appVer == nil:
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopes(cmd.Context(), pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if !o.quiet && f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
|
||||
}
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
errOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
eventKey string
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var storedScopes string
|
||||
switch {
|
||||
case pf.identity.IsBot():
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == core.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
}
|
||||
storedScopes = result.Scopes
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
|
||||
// resolveTenantToken fetches the app's tenant access token.
|
||||
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
m := make(map[string]string)
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
|
||||
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--timeout for bounded run, "+
|
||||
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
63
cmd/event/consume_stdin_test.go
Normal file
63
cmd/event/consume_stdin_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pr, _ := io.Pipe()
|
||||
defer pr.Close()
|
||||
|
||||
watchStdinEOF(pr, cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("watchStdinEOF cancelled without EOF")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
|
||||
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
watchStdinEOF(strings.NewReader(""), cancel, &buf)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
got := buf.String()
|
||||
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
143
cmd/event/consume_test.go
Normal file
143
cmd/event/consume_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want map[string]string
|
||||
wantSentry error
|
||||
wantEcho string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
in: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "single key=value",
|
||||
in: []string{"mailbox=user@example.com"},
|
||||
want: map[string]string{"mailbox": "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple pairs",
|
||||
in: []string{"a=1", "b=2", "c=3"},
|
||||
want: map[string]string{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
{
|
||||
name: "value containing = is kept intact",
|
||||
in: []string{"filter=foo=bar"},
|
||||
want: map[string]string{"filter": "foo=bar"},
|
||||
},
|
||||
{
|
||||
name: "empty value allowed",
|
||||
in: []string{"key="},
|
||||
want: map[string]string{"key": ""},
|
||||
},
|
||||
{
|
||||
name: "duplicate key — last wins",
|
||||
in: []string{"k=1", "k=2"},
|
||||
want: map[string]string{"k": "2"},
|
||||
},
|
||||
{
|
||||
name: "missing = separator",
|
||||
in: []string{"mailbox"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"mailbox"`,
|
||||
},
|
||||
{
|
||||
name: "leading = (empty key)",
|
||||
in: []string{"=value"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"=value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseParams(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
|
||||
}
|
||||
for k, v := range tc.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("key %q: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantSentry error
|
||||
}{
|
||||
{
|
||||
name: "relative path accepted",
|
||||
in: "./output",
|
||||
},
|
||||
{
|
||||
name: "nested relative path accepted",
|
||||
in: "events/today",
|
||||
},
|
||||
{
|
||||
name: "tilde rejected explicitly",
|
||||
in: "~/events",
|
||||
wantSentry: errOutputDirTilde,
|
||||
},
|
||||
{
|
||||
name: "parent escape rejected",
|
||||
in: "../outside",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
{
|
||||
name: "absolute path rejected",
|
||||
in: "/tmp/events",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitizeOutputDir(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("expected non-empty safe path, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
cmd/event/event.go
Normal file
29
cmd/event/event.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "Consume and manage real-time events",
|
||||
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
|
||||
// Without SilenceUsage, RunE errors print the full flag help banner.
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
265
cmd/event/format_helpers_test.go
Normal file
265
cmd/event/format_helpers_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStopJSON(&buf, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStopJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(got.Results))
|
||||
}
|
||||
if got.Results[0]["status"] != "stopped" {
|
||||
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
|
||||
}
|
||||
if got.Results[1]["status"] != "refused" {
|
||||
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := writeStopJSON(&buf, nil); err != nil {
|
||||
t.Fatalf("writeStopJSON(nil): %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got.Results == nil || len(got.Results) != 0 {
|
||||
t.Errorf("results = %v, want []", got.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
writeStopText(&out, &errOut, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
|
||||
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
|
||||
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
|
||||
})
|
||||
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
|
||||
t.Errorf("stopped line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
|
||||
t.Errorf("no-bus line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
|
||||
t.Errorf("refused line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
|
||||
t.Errorf("error line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
|
||||
t.Errorf("failure lines leaked to stdout: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusState_String(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
s busState
|
||||
want string
|
||||
}{
|
||||
{stateNotRunning, "not_running"},
|
||||
{stateRunning, "running"},
|
||||
{stateOrphan, "orphan"},
|
||||
} {
|
||||
if got := tc.s.String(); got != tc.want {
|
||||
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration_AllBuckets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{2 * time.Hour, "2h ago"},
|
||||
{50 * time.Hour, "2d ago"},
|
||||
} {
|
||||
if got := humanizeDuration(tc.d); got != tc.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 3661,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
|
||||
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
|
||||
},
|
||||
},
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"── cli_NOTRUNNINGXXXXXX ──",
|
||||
"Bus: not running",
|
||||
"── cli_RUNNINGXXXXXXXXX ──",
|
||||
"running (PID 1234",
|
||||
"Active consumers: 2",
|
||||
"im.message.receive_v1",
|
||||
"── cli_ORPHANXXXXXXXXXX ──",
|
||||
"orphan (PID 5678",
|
||||
"Action: kill 5678",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
|
||||
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Apps) != 2 {
|
||||
t.Fatalf("apps len = %d", len(got.Apps))
|
||||
}
|
||||
orphan := got.Apps[0]
|
||||
if orphan["status"] != "orphan" {
|
||||
t.Errorf("orphan status = %v", orphan["status"])
|
||||
}
|
||||
if orphan["suggested_action"] != "kill 99" {
|
||||
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
|
||||
}
|
||||
if orphan["issue"] == nil {
|
||||
t.Error("orphan issue missing")
|
||||
}
|
||||
run := got.Apps[1]
|
||||
if run["issue"] != nil {
|
||||
t.Errorf("running entry leaked issue: %v", run["issue"])
|
||||
}
|
||||
if run["suggested_action"] != nil {
|
||||
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan(t *testing.T) {
|
||||
orphan := []appStatus{{State: stateOrphan}}
|
||||
running := []appStatus{{State: stateRunning}}
|
||||
|
||||
if err := exitForOrphan(orphan, false); err != nil {
|
||||
t.Errorf("flag off + orphan → nil expected, got %v", err)
|
||||
}
|
||||
if err := exitForOrphan(running, false); err != nil {
|
||||
t.Errorf("flag off + running → nil expected, got %v", err)
|
||||
}
|
||||
|
||||
if err := exitForOrphan(running, true); err != nil {
|
||||
t.Errorf("flag on + no orphan → nil expected, got %v", err)
|
||||
}
|
||||
err := exitForOrphan(orphan, true)
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("consume missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Error("consume RunE is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
cmd := NewCmdStatus(f)
|
||||
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("status missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop", func(t *testing.T) {
|
||||
cmd := NewCmdStop(f)
|
||||
for _, flag := range []string{"app-id", "all", "force", "json"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("stop missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
cmd := NewCmdList(f)
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
t.Error("list missing --json flag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
cmd := NewCmdBus(f)
|
||||
if !cmd.Hidden {
|
||||
t.Error("bus should be hidden (internal daemon entrypoint)")
|
||||
}
|
||||
if cmd.Flags().Lookup("domain") == nil {
|
||||
t.Error("bus missing --domain flag")
|
||||
}
|
||||
})
|
||||
}
|
||||
121
cmd/event/list.go
Normal file
121
cmd/event/list.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available EventKeys",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(f, asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
all := eventlib.ListAll()
|
||||
|
||||
if asJSON {
|
||||
return writeListJSON(f, all)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
// stderr so `event list | jq` doesn't ingest it as a row.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
domain string
|
||||
keys []*eventlib.KeyDefinition
|
||||
}
|
||||
order := []string{}
|
||||
groups := map[string]*group{}
|
||||
|
||||
for _, def := range all {
|
||||
domain := def.Key
|
||||
if idx := strings.Index(def.Key, "."); idx > 0 {
|
||||
domain = def.Key[:idx]
|
||||
}
|
||||
g, ok := groups[domain]
|
||||
if !ok {
|
||||
g = &group{domain: domain}
|
||||
groups[domain] = g
|
||||
order = append(order, domain)
|
||||
}
|
||||
g.keys = append(g.keys, def)
|
||||
}
|
||||
|
||||
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
|
||||
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
|
||||
rowsByDomain := make(map[string][][]string, len(order))
|
||||
var allRows [][]string
|
||||
for _, domain := range order {
|
||||
for _, def := range groups[domain].keys {
|
||||
auth := "-"
|
||||
if len(def.AuthTypes) > 0 {
|
||||
auth = strings.Join(def.AuthTypes, "|")
|
||||
}
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
row := []string{
|
||||
def.Key,
|
||||
auth,
|
||||
fmt.Sprintf("%d", len(def.Params)),
|
||||
desc,
|
||||
}
|
||||
rowsByDomain[domain] = append(rowsByDomain[domain], row)
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
const colGap = " "
|
||||
widths := tableWidths(headers, allRows)
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, domain := range order {
|
||||
fmt.Fprintf(out, "\n── %s ──\n", domain)
|
||||
for _, row := range rowsByDomain[domain] {
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
// stderr keeps stdout pipe-clean for `event list | jq`.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
|
||||
type row struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
}
|
||||
rows := make([]row, len(all))
|
||||
for i, def := range all {
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, rows)
|
||||
return nil
|
||||
}
|
||||
58
cmd/event/list_test.go
Normal file
58
cmd/event/list_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("expected at least one EventKey in JSON output")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, field := range []string{"key", "event_type", "schema"} {
|
||||
if row[field] == nil {
|
||||
t.Errorf("row missing %q: %+v", field, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
cmd/event/preflight_test.go
Normal file
176
cmd/event/preflight_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
}
|
||||
return &preflightCtx{
|
||||
appID: appID,
|
||||
brand: brand,
|
||||
eventKey: key,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
EventType: "im.message.receive_v1",
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
|
||||
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.message_read_v1",
|
||||
EventType: "im.message.message_read_v1",
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.reaction",
|
||||
EventType: "im.message.reaction.created_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.message.receive_v1",
|
||||
}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "mail.receive",
|
||||
EventType: "mail.user_mailbox.event.message_received_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
"mail.user_mailbox.event.message_read_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
}}
|
||||
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing subscription")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{
|
||||
"im:message",
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
49
cmd/event/runtime.go
Normal file
49
cmd/event/runtime.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity core.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
|
||||
Method: method,
|
||||
URL: path,
|
||||
Data: body,
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
|
||||
const maxBodyEcho = 256
|
||||
body := string(resp.RawBody)
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
}
|
||||
223
cmd/event/schema.go
Normal file
223
cmd/event/schema.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
|
||||
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
base, err := renderSpec(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if base == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if isNative {
|
||||
base = schemas.WrapV2Envelope(base)
|
||||
}
|
||||
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
|
||||
return base, nil, nil
|
||||
}
|
||||
|
||||
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
|
||||
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
|
||||
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type), nil
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
buf := make(json.RawMessage, len(s.Raw))
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <EventKey>",
|
||||
Short: "Show details for an EventKey",
|
||||
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSchema(f, args[0], asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
def, ok := eventlib.Lookup(key)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(key)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
return writeSchemaJSON(f, def)
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
|
||||
fmt.Fprintf(out, "Key: %s\n", def.Key)
|
||||
if def.Description != "" {
|
||||
fmt.Fprintf(out, "Description: %s\n", def.Description)
|
||||
}
|
||||
fmt.Fprintf(out, "Event: %s\n", def.EventType)
|
||||
|
||||
if def.PreConsume != nil {
|
||||
fmt.Fprintf(out, "Pre-consume: yes\n")
|
||||
}
|
||||
|
||||
if len(def.Scopes) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Scopes:\n")
|
||||
for _, s := range def.Scopes {
|
||||
fmt.Fprintf(out, " - %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.RequiredConsoleEvents) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
|
||||
for _, e := range def.RequiredConsoleEvents {
|
||||
fmt.Fprintf(out, " - %s\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
desc := p.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
|
||||
for _, p := range def.Params {
|
||||
if len(p.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "\n %s values:\n", p.Name)
|
||||
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
for _, v := range p.Values {
|
||||
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
|
||||
}
|
||||
vw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
printIndentedJSON(out, resolved)
|
||||
} else {
|
||||
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
|
||||
if def.Schema.Native != nil {
|
||||
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
|
||||
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
|
||||
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
|
||||
var parsed json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
fmt.Fprintln(out, " <invalid JSON>")
|
||||
return
|
||||
}
|
||||
formatted, err := json.MarshalIndent(parsed, " ", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s\n", string(formatted))
|
||||
}
|
||||
|
||||
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
|
||||
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
|
||||
type payload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jqRootPath string
|
||||
if resolved != nil {
|
||||
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
|
||||
_, isNative := pickSpec(def.Schema)
|
||||
jqRootPath = "."
|
||||
if isNative {
|
||||
jqRootPath = ".event"
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, payload{
|
||||
KeyDefinition: def,
|
||||
ResolvedSchema: resolved,
|
||||
JQRootPath: jqRootPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
131
cmd/event/schema_test.go
Normal file
131
cmd/event/schema_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Key:", "im.message.receive_v1",
|
||||
"Event:", "im.message.receive_v1",
|
||||
"Output Schema:",
|
||||
`"message_id"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Output Schema:",
|
||||
`"schema"`,
|
||||
`"header"`,
|
||||
`"event"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("native schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "im.message.receive_v1") {
|
||||
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
|
||||
if _, ok := payload[field]; !ok {
|
||||
t.Errorf("JSON output missing field %q: %+v", field, payload)
|
||||
}
|
||||
}
|
||||
if payload["key"] != "im.message.receive_v1" {
|
||||
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
type out struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/sender_id": {Kind: "open_id"},
|
||||
},
|
||||
},
|
||||
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
def, _ := eventlib.Lookup(syntheticKey)
|
||||
resolved, orphans, err := resolveSchemaJSON(def)
|
||||
if err != nil || len(orphans) != 0 {
|
||||
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(resolved, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
|
||||
if got != "open_id" {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
17
cmd/event/sigpipe_unix.go
Normal file
17
cmd/event/sigpipe_unix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build unix
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
|
||||
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
|
||||
func ignoreBrokenPipe() {
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
}
|
||||
9
cmd/event/sigpipe_windows.go
Normal file
9
cmd/event/sigpipe_windows.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package event
|
||||
|
||||
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
|
||||
func ignoreBrokenPipe() {}
|
||||
328
cmd/event/status.go
Normal file
328
cmd/event/status.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
asJSON bool
|
||||
current bool
|
||||
failOnOrphan bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show event bus daemon status for all discovered apps",
|
||||
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStatus(f, current, asJSON, failOnOrphan)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type busState int
|
||||
|
||||
const (
|
||||
stateNotRunning busState = iota
|
||||
stateRunning
|
||||
stateOrphan
|
||||
)
|
||||
|
||||
func (s busState) String() string {
|
||||
switch s {
|
||||
case stateRunning:
|
||||
return "running"
|
||||
case stateOrphan:
|
||||
return "orphan"
|
||||
default:
|
||||
return "not_running"
|
||||
}
|
||||
}
|
||||
|
||||
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
|
||||
type appStatus struct {
|
||||
AppID string
|
||||
State busState
|
||||
PID int
|
||||
UptimeSec int
|
||||
Active int
|
||||
Consumers []protocol.ConsumerInfo
|
||||
}
|
||||
|
||||
type busQuerier interface {
|
||||
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
|
||||
}
|
||||
|
||||
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
|
||||
type singleAppScanner struct {
|
||||
appID string
|
||||
inner busdiscover.Scanner
|
||||
}
|
||||
|
||||
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
if s.inner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.inner.ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, p := range all {
|
||||
if p.AppID == s.appID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type transportQuerier struct {
|
||||
tr transport.IPC
|
||||
}
|
||||
|
||||
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
return busctl.QueryStatus(q.tr, appID)
|
||||
}
|
||||
|
||||
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seeds := map[string]struct{}{}
|
||||
if current {
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
} else {
|
||||
for _, id := range discoverAppIDs() {
|
||||
seeds[id] = struct{}{}
|
||||
}
|
||||
// Always include the current profile so a first-time user sees it as not_running.
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
}
|
||||
seedList := make([]string, 0, len(seeds))
|
||||
for id := range seeds {
|
||||
seedList = append(seedList, id)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
|
||||
var scanner busdiscover.Scanner
|
||||
if current {
|
||||
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
|
||||
} else {
|
||||
scanner = busdiscover.Default()
|
||||
}
|
||||
statuses := deriveStatuses(
|
||||
seedList,
|
||||
scanner,
|
||||
&transportQuerier{tr: tr},
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if asJSON {
|
||||
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
writeStatusText(f.IOStreams.Out, statuses)
|
||||
}
|
||||
return exitForOrphan(statuses, failOnOrphan)
|
||||
}
|
||||
|
||||
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
|
||||
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
|
||||
procByAppID := map[string]busdiscover.Process{}
|
||||
if sc != nil {
|
||||
if procs, err := sc.ScanBusProcesses(); err == nil {
|
||||
for _, p := range procs {
|
||||
procByAppID[p.AppID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := map[string]struct{}{}
|
||||
for _, id := range seedAppIDs {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
for id := range procByAppID {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
|
||||
type probe struct {
|
||||
resp *protocol.StatusResponse
|
||||
err error
|
||||
}
|
||||
probes := make([]probe, len(sorted))
|
||||
var wg sync.WaitGroup
|
||||
for i, appID := range sorted {
|
||||
wg.Add(1)
|
||||
go func(i int, appID string) {
|
||||
defer wg.Done()
|
||||
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
|
||||
}(i, appID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]appStatus, 0, len(sorted))
|
||||
for i, appID := range sorted {
|
||||
s := appStatus{AppID: appID, State: stateNotRunning}
|
||||
if probes[i].err == nil {
|
||||
resp := probes[i].resp
|
||||
s.State = stateRunning
|
||||
s.PID = resp.PID
|
||||
s.UptimeSec = resp.UptimeSec
|
||||
s.Active = resp.ActiveConns
|
||||
s.Consumers = resp.Consumers
|
||||
} else if p, ok := procByAppID[appID]; ok {
|
||||
s.State = stateOrphan
|
||||
s.PID = p.PID
|
||||
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// humanizeDuration formats d as a coarse "N unit ago" string.
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds ago", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", h/24)
|
||||
}
|
||||
|
||||
func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
for i, s := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintf(out, "── %s ──\n", s.AppID)
|
||||
switch s.State {
|
||||
case stateNotRunning:
|
||||
fmt.Fprintln(out, " Bus: not running")
|
||||
case stateRunning:
|
||||
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
}
|
||||
widths := tableWidths(headers, rows)
|
||||
const colGap = " "
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, row := range rows {
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
case stateOrphan:
|
||||
if s.PID == 0 {
|
||||
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
|
||||
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
|
||||
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
|
||||
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
|
||||
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
|
||||
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
|
||||
type jsonStatus struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
Running bool `json:"running"` // backward compat
|
||||
PID int `json:"pid,omitempty"`
|
||||
UptimeSec int `json:"uptime_sec,omitempty"`
|
||||
Active int `json:"active_consumers,omitempty"`
|
||||
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
SuggestedAction string `json:"suggested_action,omitempty"`
|
||||
}
|
||||
payload := make([]jsonStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
js := jsonStatus{
|
||||
AppID: s.AppID,
|
||||
Status: s.State.String(),
|
||||
Running: s.State == stateRunning,
|
||||
PID: s.PID,
|
||||
UptimeSec: s.UptimeSec,
|
||||
Active: s.Active,
|
||||
Consumers: s.Consumers,
|
||||
}
|
||||
if s.State == stateOrphan {
|
||||
if s.PID == 0 {
|
||||
js.Issue = "live bus detected but pid file is missing or corrupt"
|
||||
js.SuggestedAction = "inspect events dir and kill manually"
|
||||
} else {
|
||||
js.Issue = "socket file missing"
|
||||
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
|
||||
}
|
||||
}
|
||||
payload = append(payload, js)
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"apps": payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
|
||||
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
|
||||
if !failOnOrphan {
|
||||
return nil
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if s.State == stateOrphan {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
cmd/event/status_fail_on_orphan_test.go
Normal file
48
cmd/event/status_fail_on_orphan_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
err := exitForOrphan(statuses, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_NoOrphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateNotRunning},
|
||||
}
|
||||
if err := exitForOrphan(statuses, true); err != nil {
|
||||
t.Errorf("expected nil error when no orphan; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_FlagDisabled(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
if err := exitForOrphan(statuses, false); err != nil {
|
||||
t.Errorf("flag off should never return error; got %v", err)
|
||||
}
|
||||
}
|
||||
242
cmd/event/status_orphan_test.go
Normal file
242
cmd/event/status_orphan_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type fakeScanner struct {
|
||||
procs []busdiscover.Process
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
return f.procs, f.err
|
||||
}
|
||||
|
||||
type fakeBusQuerier struct {
|
||||
respByAppID map[string]*protocol.StatusResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
if r, ok := f.respByAppID[appID]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_RunningBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning", s.State)
|
||||
}
|
||||
if s.PID != 12345 {
|
||||
t.Errorf("PID = %d, want 12345", s.PID)
|
||||
}
|
||||
if s.UptimeSec != 150 {
|
||||
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_OrphanBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
|
||||
}}
|
||||
|
||||
now := time.Now()
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateOrphan {
|
||||
t.Errorf("State = %v, want stateOrphan", s.State)
|
||||
}
|
||||
if s.PID != 70926 {
|
||||
t.Errorf("PID = %d, want 70926", s.PID)
|
||||
}
|
||||
wantUptime := int((19 * time.Hour).Seconds())
|
||||
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
|
||||
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_NotRunning(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateNotRunning {
|
||||
t.Errorf("State = %v, want stateNotRunning", s.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
|
||||
}}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
|
||||
}
|
||||
byID := map[string]appStatus{}
|
||||
for _, s := range statuses {
|
||||
byID[s.AppID] = s
|
||||
}
|
||||
if byID["cli_known"].State != stateNotRunning {
|
||||
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
|
||||
}
|
||||
if byID["cli_orphan"].State != stateOrphan {
|
||||
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{err: errors.New("ps failed")}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_OrphanBlock(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
writeStatusText(&buf, statuses)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"── cli_XXXXXXXXXXXXXXXX ──",
|
||||
"Bus: orphan (PID 70926, started 19h ago)",
|
||||
"Issue: socket file missing — consumers cannot connect",
|
||||
"Action: kill 70926",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\nfull output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "running (PID") {
|
||||
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
|
||||
}
|
||||
a := payload.Apps[0]
|
||||
if a["status"] != "orphan" {
|
||||
t.Errorf("status = %v, want \"orphan\"", a["status"])
|
||||
}
|
||||
if a["running"] != false {
|
||||
t.Errorf("running = %v, want false", a["running"])
|
||||
}
|
||||
if a["issue"] != "socket file missing" {
|
||||
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
|
||||
}
|
||||
if a["suggested_action"] != "kill 70926" {
|
||||
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
|
||||
}
|
||||
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
|
||||
t.Errorf("pid = %v, want 70926", a["pid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_running",
|
||||
State: stateRunning,
|
||||
PID: 11111,
|
||||
UptimeSec: 60,
|
||||
Active: 0,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, `"issue"`) {
|
||||
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"suggested_action"`) {
|
||||
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{45 * time.Minute, "45m ago"},
|
||||
{90 * time.Minute, "1h ago"},
|
||||
{5 * time.Hour, "5h ago"},
|
||||
{30 * time.Hour, "1d ago"},
|
||||
{80 * time.Hour, "3d ago"},
|
||||
} {
|
||||
got := humanizeDuration(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
cmd/event/stop.go
Normal file
241
cmd/event/stop.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
|
||||
type stopStatus string
|
||||
|
||||
const (
|
||||
stopStopped stopStatus = "stopped"
|
||||
stopNoBus stopStatus = "no_bus"
|
||||
stopRefused stopStatus = "refused"
|
||||
stopErrored stopStatus = "error"
|
||||
)
|
||||
|
||||
type stopResult struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status stopStatus `json:"status"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type stopCmdOpts struct {
|
||||
appID string
|
||||
all bool
|
||||
force bool
|
||||
asJSON bool
|
||||
}
|
||||
|
||||
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
var o stopCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the event bus daemon",
|
||||
Long: `Stop the event bus daemon. Target is one of:
|
||||
• the current profile's AppID (default)
|
||||
• an explicit AppID via --app-id
|
||||
• every running bus on this machine via --all
|
||||
|
||||
Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
|
||||
--force widens two gates:
|
||||
1. Allows stopping a bus that still has active consumers.
|
||||
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
|
||||
process and cleans up the stale socket instead of returning an
|
||||
error.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStop(f, o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
|
||||
tr := transport.New()
|
||||
|
||||
var targets []string
|
||||
if o.all {
|
||||
targets = discoverAppIDs()
|
||||
} else {
|
||||
targetAppID := o.appID
|
||||
if targetAppID == "" {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetAppID = cfg.AppID
|
||||
}
|
||||
targets = []string{targetAppID}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, nil)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]stopResult, 0, len(targets))
|
||||
for _, id := range targets {
|
||||
results = append(results, stopBusOne(tr, id, o.force))
|
||||
}
|
||||
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, results)
|
||||
}
|
||||
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
|
||||
|
||||
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
|
||||
for _, r := range results {
|
||||
if r.Status == stopRefused || r.Status == stopErrored {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
|
||||
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
|
||||
resp, err := busctl.QueryStatus(tr, appID)
|
||||
if err != nil {
|
||||
return stopResult{AppID: appID, Status: stopNoBus}
|
||||
}
|
||||
|
||||
if resp.ActiveConns > 0 && !force {
|
||||
pids := make([]int, len(resp.Consumers))
|
||||
for i, c := range resp.Consumers {
|
||||
pids[i] = c.PID
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopRefused,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
|
||||
}
|
||||
}
|
||||
|
||||
if err := busctl.SendShutdown(tr, appID); err != nil {
|
||||
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
|
||||
}
|
||||
|
||||
const pollInterval = 100 * time.Millisecond
|
||||
deadline := time.Now().Add(shutdownBudget)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
probe, dialErr := tr.Dial(tr.Address(appID))
|
||||
if dialErr != nil {
|
||||
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
|
||||
}
|
||||
probe.Close()
|
||||
}
|
||||
|
||||
if !force {
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
|
||||
}
|
||||
}
|
||||
|
||||
// --force: SIGKILL and clean up the stale socket.
|
||||
if err := killProcess(resp.PID); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
// Bus exited between timeout and kill — treat as success.
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "bus exited during kill attempt",
|
||||
}
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
|
||||
}
|
||||
}
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "killed (ungraceful) after shutdown timeout",
|
||||
}
|
||||
}
|
||||
|
||||
// killProcess is a var so tests can swap it without spawning sub-processes.
|
||||
var killProcess = func(pid int) error {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
|
||||
var shutdownBudget = 5 * time.Second
|
||||
|
||||
func writeStopJSON(w io.Writer, results []stopResult) error {
|
||||
if results == nil {
|
||||
results = []stopResult{}
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"results": results})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStopText(out, errOut io.Writer, results []stopResult) {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case stopStopped:
|
||||
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
|
||||
case stopNoBus:
|
||||
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
|
||||
case stopRefused:
|
||||
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
|
||||
case stopErrored:
|
||||
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
|
||||
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
|
||||
func discoverAppIDs() []string {
|
||||
procs, err := busdiscover.Default().ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
ids = append(ids, p.AppID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
73
cmd/event/stop_discover_test.go
Normal file
73
cmd/event/stop_discover_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
)
|
||||
|
||||
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
eventsDir := filepath.Join(tmp, "events")
|
||||
|
||||
// Two live buses (lock held until t.Cleanup releases it).
|
||||
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
|
||||
appDir := filepath.Join(eventsDir, app)
|
||||
h, err := busdiscover.WritePIDFile(appDir, 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile %s: %v", app, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
}
|
||||
|
||||
// Dead bus: lock acquired then released → looks like a stale dir on disk.
|
||||
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
|
||||
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
|
||||
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
|
||||
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stray non-dir file under events/.
|
||||
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := discoverAppIDs()
|
||||
sort.Strings(got)
|
||||
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if got := discoverAppIDs(); len(got) != 0 {
|
||||
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
340
cmd/event/stop_integration_test.go
Normal file
340
cmd/event/stop_integration_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
cleaned bool
|
||||
}
|
||||
|
||||
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return net.Listen("tcp", addr)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Address(appID string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.addr
|
||||
}
|
||||
|
||||
func (t *mockTransport) Cleanup(addr string) {
|
||||
t.mu.Lock()
|
||||
t.cleaned = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *mockTransport) didCleanup() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleaned
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
listener net.Listener
|
||||
pid int
|
||||
exitDelay time.Duration
|
||||
unresponsive bool
|
||||
|
||||
shutdownCount int32
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
b := &fakeBus{
|
||||
listener: ln,
|
||||
pid: pid,
|
||||
exitDelay: exitDelay,
|
||||
unresponsive: unresponsive,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.serve()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
|
||||
|
||||
func (b *fakeBus) serve() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) handle(conn net.Conn) {
|
||||
defer b.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case *protocol.StatusQuery:
|
||||
_ = protocol.Encode(conn, &protocol.StatusResponse{
|
||||
Type: protocol.MsgTypeStatusResponse,
|
||||
PID: b.pid,
|
||||
UptimeSec: 1,
|
||||
ActiveConns: 0,
|
||||
Consumers: nil,
|
||||
})
|
||||
case *protocol.Shutdown:
|
||||
atomic.AddInt32(&b.shutdownCount, 1)
|
||||
if b.unresponsive {
|
||||
return
|
||||
}
|
||||
if b.exitDelay > 0 {
|
||||
go func() {
|
||||
time.Sleep(b.exitDelay)
|
||||
b.stop()
|
||||
}()
|
||||
} else {
|
||||
go b.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) stop() {
|
||||
b.stopOnce.Do(func() {
|
||||
_ = b.listener.Close()
|
||||
close(b.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(budget):
|
||||
t.Fatalf("fakeBus did not shut down within %v", budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
|
||||
const pid = 44441
|
||||
const exitDelay = 500 * time.Millisecond
|
||||
|
||||
bus := newFakeBus(t, pid, exitDelay, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Fatalf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < 400*time.Millisecond {
|
||||
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
|
||||
}
|
||||
|
||||
bus.wait(t, 2*time.Second)
|
||||
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
|
||||
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
|
||||
const pid = 44442
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "did not exit within") {
|
||||
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 0 {
|
||||
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
|
||||
}
|
||||
if tr.didCleanup() {
|
||||
t.Errorf("Cleanup should not be called when --force is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
|
||||
const pid = 44443
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "killed") {
|
||||
t.Errorf("reason %q should mention 'killed'", res.Reason)
|
||||
}
|
||||
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
|
||||
}
|
||||
if !tr.didCleanup() {
|
||||
t.Errorf("Cleanup was not invoked after force-kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
|
||||
const pid = 12345
|
||||
|
||||
bus := newFakeBus(t, pid, 0, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("expected PID=%d, got %d", pid, res.PID)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
|
||||
const pid = 99999
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
|
||||
}
|
||||
killMu.Unlock()
|
||||
if !tr.didCleanup() {
|
||||
t.Error("expected Cleanup to be called even when kill reported already-dead")
|
||||
}
|
||||
if !strings.Contains(res.Reason, "exited during kill attempt") {
|
||||
t.Errorf("expected reason about race, got %q", res.Reason)
|
||||
}
|
||||
}
|
||||
102
cmd/event/suggestions.go
Normal file
102
cmd/event/suggestions.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
|
||||
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
|
||||
func suggestEventKeys(input string) []string {
|
||||
type match struct {
|
||||
key string
|
||||
dist int
|
||||
}
|
||||
var hits []match
|
||||
threshold := max(2, len(input)/5)
|
||||
|
||||
for _, def := range eventlib.ListAll() {
|
||||
if strings.Contains(def.Key, input) {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
|
||||
|
||||
n := min(maxSuggestions, len(hits))
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = hits[i].key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatSuggestions renders keys as a human-readable quoted tail.
|
||||
func formatSuggestions(keys []string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
if len(quoted) == 1 {
|
||||
return quoted[0]
|
||||
}
|
||||
return "one of: " + strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
|
||||
func unknownEventKeyErr(key string) error {
|
||||
msg := fmt.Sprintf("unknown EventKey: %s", key)
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
150
cmd/event/suggestions_test.go
Normal file
150
cmd/event/suggestions_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantEmpty bool
|
||||
wantAllHavePrefix string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "typo via Levenshtein (recieve → receive)",
|
||||
input: "im.message.recieve_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
{
|
||||
name: "substring match returns im.message.* keys",
|
||||
input: "im.message",
|
||||
wantAllHavePrefix: "im.message.",
|
||||
},
|
||||
{
|
||||
name: "completely unrelated input returns empty",
|
||||
input: "xyzzy_no_such_event_key_at_all",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "exact key is a substring of itself",
|
||||
input: "im.message.receive_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := suggestEventKeys(tc.input)
|
||||
if tc.wantEmpty {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty suggestions, got nothing")
|
||||
}
|
||||
if len(got) > maxSuggestions {
|
||||
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
|
||||
}
|
||||
if tc.wantAllHavePrefix != "" {
|
||||
for _, k := range got {
|
||||
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
|
||||
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.wantContains != "" {
|
||||
found := false
|
||||
for _, k := range got {
|
||||
if k == tc.wantContains {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{name: "empty → empty string", in: nil, want: ""},
|
||||
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
|
||||
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
|
||||
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := formatSuggestions(tc.in); got != tc.want {
|
||||
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("im.message.recieve_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"unknown EventKey: im.message.recieve_v1",
|
||||
"did you mean",
|
||||
"im.message.receive_v1",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "did you mean") {
|
||||
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
|
||||
}
|
||||
}
|
||||
39
cmd/event/table.go
Normal file
39
cmd/event/table.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// tableWidths returns the max cell width per column across headers + rows.
|
||||
func tableWidths(headers []string, rows [][]string) []int {
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if l := len(cell); l > widths[i] {
|
||||
widths[i] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
|
||||
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
|
||||
for i, cell := range cells {
|
||||
if i == len(cells)-1 {
|
||||
fmt.Fprintln(out, cell)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,38 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/pflag"
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree.
|
||||
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
|
||||
// is a build-time policy — when true, --profile stays parseable but is marked
|
||||
// hidden from help and shell completion.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
Profile string
|
||||
HideProfile bool
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
// RegisterGlobalFlags registers the root-level persistent flags on fs and
|
||||
// applies any visibility policy encoded in opts. Pure function: no disk,
|
||||
// network, or environment reads — the caller decides HideProfile.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
if opts.HideProfile {
|
||||
_ = fs.MarkHidden("profile")
|
||||
}
|
||||
}
|
||||
|
||||
// isSingleAppMode reports whether the on-disk config has at most one app.
|
||||
// Missing configs are treated as single-app since --profile is meaningless
|
||||
// until at least two profiles exist. Intended for the Execute entry point —
|
||||
// buildInternal must not call this directly to stay state-free.
|
||||
func isSingleAppMode() bool {
|
||||
raw, err := core.LoadMultiAppConfig()
|
||||
if err != nil || raw == nil {
|
||||
return true
|
||||
}
|
||||
return len(raw.Apps) <= 1
|
||||
}
|
||||
|
||||
110
cmd/global_flags_test.go
Normal file
110
cmd/global_flags_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible when HideProfile is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{HideProfile: true}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile is true")
|
||||
}
|
||||
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
|
||||
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
|
||||
}
|
||||
if opts.Profile != "x" {
|
||||
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_NoConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true when no config exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true for single-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered by default")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible by default")
|
||||
}
|
||||
}
|
||||
|
||||
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
18
cmd/init.go
Normal file
18
cmd/init.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
// SetDefaultFS replaces the global filesystem implementation used by internal
|
||||
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
|
||||
// the default OS filesystem is restored.
|
||||
//
|
||||
// Call this before Build or Execute to take effect.
|
||||
func SetDefaultFS(fs vfs.FS) {
|
||||
if fs == nil {
|
||||
fs = vfs.OsFs{}
|
||||
}
|
||||
vfs.DefaultFS = fs
|
||||
}
|
||||
@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
|
||||
@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
|
||||
@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
120
cmd/root.go
120
cmd/root.go
@@ -14,22 +14,14 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -56,7 +48,7 @@ EXAMPLES:
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot | auto (default: auto)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
@@ -94,41 +86,17 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
@@ -137,42 +105,54 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupUpdateNotice starts an async update check and wires the output decorator.
|
||||
func setupUpdateNotice() {
|
||||
// Sync: check cache immediately (no network, fast).
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
|
||||
// Async: refresh cache for this run (and future runs).
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(build.Version)
|
||||
// If cache was just populated for the first time, set pending now.
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wire the output decorator so JSON envelopes include "_notice".
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
info := update.GetPending()
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
},
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +168,12 @@ func isCompletionCommand(args []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
@@ -206,6 +192,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
@@ -275,16 +262,29 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips.
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Risk:", level)
|
||||
}
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,11 +15,14 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -135,10 +139,12 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
f := cmdutil.NewDefault(
|
||||
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
||||
cmdutil.InvocationContext{Profile: profile},
|
||||
)
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
@@ -147,20 +153,6 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
@@ -355,11 +347,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -376,7 +372,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -400,7 +397,26 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -408,16 +424,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentit
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
@@ -432,12 +447,13 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -445,16 +461,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
@@ -488,3 +503,176 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
// Reset pending state to ensure a clean test.
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
return // expected — no pending notices at all
|
||||
}
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in in-sync state: %+v", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for drift")
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update"
|
||||
if msg, _ := skills["message"].(string); msg != want {
|
||||
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
||||
// emits BOTH "_notice.update" and "_notice.skills" keys when each
|
||||
// pending value is set. Drives the skills key via setupNotices() (drift
|
||||
// state) and manually populates the update pending afterwards, since
|
||||
// clearNoticeEnv suppresses the update goroutine to avoid network
|
||||
// flakiness.
|
||||
func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
// goroutine is suppressed by clearNoticeEnv.
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"})
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want both keys")
|
||||
}
|
||||
if _, ok := notice["update"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'update' key: %+v", notice)
|
||||
}
|
||||
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'skills' key: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// clearNoticeEnv unsets the env vars that affect either notice. We
|
||||
// proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1)
|
||||
// because setupNotices spawns a goroutine that hits the npm registry —
|
||||
// tests focused on the skills check should not depend on network state.
|
||||
func clearNoticeEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER",
|
||||
"CI", "BUILD_NUMBER", "RUN_ID",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
// Suppress the update goroutine's network call deterministically.
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
|
||||
70
cmd/root_risk_help_test.go
Normal file
70
cmd/root_risk_help_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if !strings.Contains(out, "Risk: high-risk-write") {
|
||||
t.Errorf("expected Risk line in help output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if strings.Contains(out, "Risk:") {
|
||||
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
cmdutil.SetTips(child, []string{"use --yes to confirm"})
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
riskIdx := strings.Index(out, "Risk:")
|
||||
tipsIdx := strings.Index(out, "Tips:")
|
||||
if riskIdx == -1 || tipsIdx == -1 {
|
||||
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
|
||||
}
|
||||
if riskIdx >= tipsIdx {
|
||||
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
|
||||
}
|
||||
}
|
||||
146
cmd/root_test.go
146
cmd/root_test.go
@@ -11,9 +11,12 @@ import (
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
@@ -188,6 +191,124 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
exitErr.Detail.Hint = "existing hint"
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if exitErr.Detail.Hint != want {
|
||||
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
@@ -196,3 +317,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDisabled bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, true},
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// SchemaOptions holds all inputs for the schema command.
|
||||
type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
@@ -41,7 +44,7 @@ func printServices(w io.Writer) {
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
@@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -73,6 +80,12 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
@@ -80,6 +93,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
@@ -138,11 +152,25 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -184,7 +212,13 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
}
|
||||
|
||||
// CLI example
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
@@ -332,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -340,9 +375,9 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -352,78 +387,86 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// afterService = everything user typed after "serviceName."
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
// afterService is a prefix of this resource name → resource candidate
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
} else if strings.HasPrefix(afterService, resName+".") {
|
||||
// This resource is fully matched; remainder is method prefix
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
|
||||
// If all completions end with ".", user is still navigating resources → NoSpace
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
return completions
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
@@ -442,9 +485,9 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec)
|
||||
printResourceList(out, spec, mode)
|
||||
} else {
|
||||
output.PrintJson(out, spec)
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -465,6 +508,7 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -473,13 +517,26 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
@@ -498,3 +555,67 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -61,3 +62,169 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import (
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
RegisterServiceCommandsWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
@@ -38,11 +42,15 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerService(parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
@@ -70,11 +78,11 @@ func registerService(parent *cobra.Command, spec map[string]interface{}, resourc
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResource(svc, spec, resName, resMap, f)
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
@@ -87,7 +95,7 @@ func registerResource(parent *cobra.Command, spec map[string]interface{}, name s
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethod(res, spec, methodMap, methodName, name, f)
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,26 +109,38 @@ type ServiceMethodOptions struct {
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
|
||||
@@ -147,12 +167,12 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
}
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
@@ -160,15 +180,25 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
@@ -212,15 +242,24 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
request, err := buildServiceRequest(opts)
|
||||
request, fileMeta, err := buildServiceRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
}
|
||||
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -244,13 +283,14 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
}
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CheckError: checkErr,
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -303,7 +343,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
@@ -312,12 +354,18 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
@@ -330,13 +378,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required path parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("%s", err)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
@@ -352,7 +400,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required query parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
@@ -366,22 +414,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
}
|
||||
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
URL: url,
|
||||
Params: queryParams,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload: determine default field name from metadata.
|
||||
defaultField := "file"
|
||||
if len(opts.FileFields) == 1 {
|
||||
defaultField = opts.FileFields[0]
|
||||
}
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
|
||||
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
114
cmd/service/service_risk_test.go
Normal file
114
cmd/service/service_risk_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("expected --yes flag registered for risk=high-risk-write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagNotRegisteredForWrite(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") != nil {
|
||||
t.Error("expected --yes flag NOT registered when risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationSet(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
t.Fatal("expected Risk annotation to be set")
|
||||
}
|
||||
if level != "high-risk-write" {
|
||||
t.Errorf("level = %q, want high-risk-write", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationAbsentForUnsetRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if _, ok := cmdutil.GetRisk(cmd); ok {
|
||||
t.Error("expected no Risk annotation when meta risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_GateBlocksWithoutYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
// --as bot skips the scope check so we reach the gate without external creds.
|
||||
cmd.SetArgs([]string{"--as", "bot", "--params", `{"file_token":"tok_abc"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Errorf("expected 'requires confirmation' in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "drive.files.delete") {
|
||||
t.Errorf("expected schema path in error action, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunBypassesGate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--as", "bot",
|
||||
"--params", `{"file_token":"tok_abc"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run should not hit confirmation gate; got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "files/tok_abc") {
|
||||
t.Errorf("expected dry-run output to contain URL, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -120,6 +121,24 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("expected --as flag to be hidden in strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
}
|
||||
}
|
||||
|
||||
// ── NewCmdServiceMethod flags ──
|
||||
|
||||
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
@@ -710,6 +729,144 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --file flag to be registered for file upload method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for non-file method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
getMethod := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "GET",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--file", "image=" + tmpFile,
|
||||
"--data", `{"image_type":"message"}`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file fields",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
|
||||
421
cmd/update/update.go
Normal file
421
cmd/update/update.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
maxStderrDetail = 500
|
||||
osWindows = "windows"
|
||||
)
|
||||
|
||||
// Overridable for testing.
|
||||
var (
|
||||
fetchLatest = func() (string, error) { return update.FetchLatest() }
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
}
|
||||
|
||||
func releaseURL(version string) string {
|
||||
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
|
||||
}
|
||||
|
||||
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
|
||||
|
||||
// --- Terminal symbols (ASCII fallback on Windows) ---
|
||||
|
||||
func symOK() string {
|
||||
if isWindows() {
|
||||
return "[OK]"
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
|
||||
func symFail() string {
|
||||
if isWindows() {
|
||||
return "[FAIL]"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
func symWarn() string {
|
||||
if isWindows() {
|
||||
return "[WARN]"
|
||||
}
|
||||
return "⚠"
|
||||
}
|
||||
|
||||
func symArrow() string {
|
||||
if isWindows() {
|
||||
return "->"
|
||||
}
|
||||
return "→"
|
||||
}
|
||||
|
||||
// --- Command ---
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update lark-cli to the latest version",
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
|
||||
}
|
||||
|
||||
// 2. Validate version format
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
|
||||
}
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
detect := updater.DetectInstallMethod()
|
||||
|
||||
// 5. --check
|
||||
if opts.Check {
|
||||
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
|
||||
}
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
|
||||
})
|
||||
return output.ErrBare(exitCode)
|
||||
}
|
||||
return output.Errorf(exitCode, errType, "%s", msg)
|
||||
}
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "update_available",
|
||||
"auto_update": canAutoUpdate,
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if canAutoUpdate {
|
||||
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "latest_version": latest,
|
||||
"action": "manual_required",
|
||||
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
applySkillsResult(out, skillsResult)
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
if npmResult.Stdout.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
|
||||
}
|
||||
if npmResult.Stderr.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Verify the new binary is functional before proceeding.
|
||||
// If corrupt, restore the previous version from .old.
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": latest,
|
||||
"latest_version": latest, "action": "updated",
|
||||
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
applySkillsResult(result, skillsResult)
|
||||
output.PrintJson(io.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
if !force {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
if !check {
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
}
|
||||
1219
cmd/update/update_test.go
Normal file
1219
cmd/update/update_test.go
Normal file
File diff suppressed because it is too large
Load Diff
85
events/im/message_receive.go
Normal file
85
events/im/message_receive.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
)
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
})
|
||||
}
|
||||
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = msg.CreateTime
|
||||
}
|
||||
|
||||
out := &ImMessageReceiveOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
ID: msg.MessageID,
|
||||
MessageID: msg.MessageID,
|
||||
CreateTime: msg.CreateTime,
|
||||
ChatID: msg.ChatID,
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
Content: content,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
190
events/im/message_receive_test.go
Normal file
190
events/im/message_receive_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("im.message.receive_v1")
|
||||
if !ok {
|
||||
t.Fatal("im.message.receive_v1 should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for Processed key")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty — preflightScopes would bypass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMKeys_NativeEventsRegistered(t *testing.T) {
|
||||
want := []string{
|
||||
"im.message.message_read_v1",
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.chat.member.bot.added_v1",
|
||||
"im.chat.member.bot.deleted_v1",
|
||||
"im.chat.member.user.added_v1",
|
||||
"im.chat.member.user.withdrawn_v1",
|
||||
"im.chat.member.user.deleted_v1",
|
||||
"im.chat.updated_v1",
|
||||
"im.chat.disbanded_v1",
|
||||
}
|
||||
for _, k := range want {
|
||||
def, ok := event.Lookup(k)
|
||||
if !ok {
|
||||
t.Errorf("%s should be registered via Keys()", k)
|
||||
continue
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Errorf("%s: Schema.Native must be set for native key", k)
|
||||
}
|
||||
if def.Schema.Custom != nil {
|
||||
t.Errorf("%s: Native key must not set Schema.Custom", k)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Errorf("%s: Native key must not set Process", k)
|
||||
}
|
||||
if def.Schema.Native != nil && def.Schema.Native.Type == nil {
|
||||
t.Errorf("%s: Schema.Native.Type must reference an SDK type", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageID != "om_text_001" || out.ID != "om_text_001" {
|
||||
t.Errorf("MessageID/ID = %q/%q", out.MessageID, out.ID)
|
||||
}
|
||||
if out.ChatType != "p2p" || out.ChatID != "oc_chat" {
|
||||
t.Errorf("chat_id/chat_type = %q/%q", out.ChatID, out.ChatType)
|
||||
}
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_card",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469274",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_card_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "group",
|
||||
"message_type": "interactive",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"A card\"}}}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageType != "interactive" {
|
||||
t.Errorf("MessageType = %q", out.MessageType)
|
||||
}
|
||||
if out.ChatType != "group" {
|
||||
t.Errorf("ChatType = %q", out.ChatType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out ImMessageReceiveOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid ImMessageReceiveOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
184
events/im/native.go
Normal file
184
events/im/native.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// nativeIMKey curates metadata for a Native IM event; fieldOverrides paths are JSON Pointer anchored at the V2-wrapped schema (start with /event/...).
|
||||
type nativeIMKey struct {
|
||||
key string
|
||||
title string
|
||||
description string
|
||||
scopes []string
|
||||
bodyType reflect.Type
|
||||
fieldOverrides map[string]schemas.FieldMeta
|
||||
}
|
||||
|
||||
// userIDOv returns open_id/union_id/user_id overrides for a UserID object at prefix.
|
||||
func userIDOv(prefix string) map[string]schemas.FieldMeta {
|
||||
return map[string]schemas.FieldMeta{
|
||||
prefix + "/open_id": {Kind: "open_id"},
|
||||
prefix + "/union_id": {Kind: "union_id"},
|
||||
prefix + "/user_id": {Kind: "user_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeOv merges FieldMeta maps left-to-right (later wins).
|
||||
func mergeOv(ms ...map[string]schemas.FieldMeta) map[string]schemas.FieldMeta {
|
||||
out := map[string]schemas.FieldMeta{}
|
||||
for _, m := range ms {
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var nativeIMKeys = []nativeIMKey{
|
||||
{
|
||||
key: "im.message.message_read_v1",
|
||||
title: "Message read",
|
||||
description: "Triggered after a user reads a P2P message sent by the bot",
|
||||
scopes: []string{"im:message:readonly", "im:message"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReadV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/reader/reader_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/reader/read_time": {Kind: "timestamp_ms"},
|
||||
"/event/message_id_list/*": {Kind: "message_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.created_v1",
|
||||
title: "Reaction added",
|
||||
description: "Triggered when a reaction is added to a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionCreatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.deleted_v1",
|
||||
title: "Reaction removed",
|
||||
description: "Triggered when a reaction is removed from a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.added_v1",
|
||||
title: "Bot added to chat",
|
||||
description: "Triggered when the bot is added to a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.deleted_v1",
|
||||
title: "Bot removed from chat",
|
||||
description: "Triggered after the bot is removed from a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.added_v1",
|
||||
title: "User added to chat",
|
||||
description: "Triggered when a new user joins a chat (including topic chats)",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.withdrawn_v1",
|
||||
title: "User invite withdrawn",
|
||||
description: "Triggered after a pending user invite is withdrawn",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserWithdrawnV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.deleted_v1",
|
||||
title: "User left chat",
|
||||
description: "Triggered when a user leaves or is removed from a chat",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.updated_v1",
|
||||
title: "Chat updated",
|
||||
description: "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatUpdatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/before_change/owner_id"),
|
||||
userIDOv("/event/after_change/owner_id"),
|
||||
userIDOv("/event/moderator_list/added_member_list/*/user_id"),
|
||||
userIDOv("/event/moderator_list/removed_member_list/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.disbanded_v1",
|
||||
title: "Chat disbanded",
|
||||
description: "Triggered after a chat is disbanded",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatDisbandedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
49
events/im/register.go
Normal file
49
events/im/register.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package im registers IM-domain EventKeys.
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Keys returns all IM-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
out := []event.KeyDefinition{
|
||||
{
|
||||
Key: "im.message.receive_v1",
|
||||
DisplayName: "Receive message",
|
||||
Description: "Receive IM messages",
|
||||
EventType: "im.message.receive_v1",
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ImMessageReceiveOutput{})},
|
||||
},
|
||||
Process: processImMessageReceive,
|
||||
// Narrowest grant; kept single-element since MissingScopes uses AND semantics.
|
||||
Scopes: []string{"im:message.p2p_msg:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
out = append(out, event.KeyDefinition{
|
||||
Key: rk.key,
|
||||
DisplayName: rk.title,
|
||||
Description: rk.description,
|
||||
EventType: rk.key,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: rk.bodyType},
|
||||
FieldOverrides: rk.fieldOverrides,
|
||||
},
|
||||
Scopes: rk.scopes,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{rk.key},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user