mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
619ec8c2cb | ||
|
|
eb3c643f0b | ||
|
|
37747177fc | ||
|
|
9d48ef422b | ||
|
|
e64d24580a | ||
|
|
3db4f42ab8 | ||
|
|
0bf4f80ef4 | ||
|
|
284e5b6606 | ||
|
|
af83e5495b | ||
|
|
a3bced3ee5 | ||
|
|
35108e1798 | ||
|
|
30b97e1bdd | ||
|
|
2715b560b7 | ||
|
|
15bd134f5c | ||
|
|
daa21731ad | ||
|
|
9fab62bf00 | ||
|
|
cdd9f9ab49 | ||
|
|
d5d31f0ee4 | ||
|
|
67cb0a961e | ||
|
|
aa4076a7cc | ||
|
|
db9ca5c2a4 | ||
|
|
1f8d4b211d | ||
|
|
63ea52b2e6 | ||
|
|
555722ac8e | ||
|
|
f5a8fbf8f1 | ||
|
|
adef52ada5 | ||
|
|
6ac5b4d566 | ||
|
|
7158dc2f3c | ||
|
|
c54a1354a0 | ||
|
|
a73c9ae27e | ||
|
|
900c12ce8d | ||
|
|
f3c3a4c49f | ||
|
|
2e345a4fdd | ||
|
|
78bc66ce14 | ||
|
|
fe8da8d924 | ||
|
|
12bb01addf | ||
|
|
d6fada01f5 | ||
|
|
6bc6bb67aa | ||
|
|
a1438586ec | ||
|
|
c9b660ae12 | ||
|
|
567b40778b | ||
|
|
ec23995bce | ||
|
|
1980b999f7 | ||
|
|
1be9a241b7 | ||
|
|
f4afa47de8 | ||
|
|
bb38ecd41a | ||
|
|
9f0758bfef | ||
|
|
d3d92e37c2 | ||
|
|
b064188f20 | ||
|
|
799179fde6 | ||
|
|
8db4528269 | ||
|
|
30dba35c77 | ||
|
|
2efadece34 | ||
|
|
b7613d64bd | ||
|
|
0c77c95a11 | ||
|
|
135fde8b6d | ||
|
|
5cf866739d | ||
|
|
77460abc49 | ||
|
|
a641fdd5e6 | ||
|
|
8645d26d09 | ||
|
|
b5b23fe82a | ||
|
|
84258980c6 | ||
|
|
51a6adab2b | ||
|
|
9e367b4736 | ||
|
|
56ed529c1b | ||
|
|
f67f569e76 | ||
|
|
f930d9c52f | ||
|
|
7c3d5b31d5 | ||
|
|
bf537f8d9c | ||
|
|
10caeb5788 | ||
|
|
6a4dd8dc1b | ||
|
|
1f3d9e0420 | ||
|
|
6692300468 | ||
|
|
7baba213bc | ||
|
|
725a62879b | ||
|
|
112dd5f6b2 | ||
|
|
0f96bdf5e8 | ||
|
|
102ee51914 | ||
|
|
79f43dc337 | ||
|
|
f231031041 | ||
|
|
f68a41163e |
@@ -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
|
||||
54
.github/workflows/coverage.yml
vendored
54
.github/workflows/coverage.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- 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: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
- 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
|
||||
57
.github/workflows/issue-labels.yml
vendored
Normal file
57
.github/workflows/issue-labels.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Issue Labels
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Do not write labels, only print planned changes"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-issue-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# v6+ uses Node 24 runtime for JavaScript actions.
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Sync managed issue labels
|
||||
id: sync_issue_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
args=(
|
||||
"--max-issues" "300"
|
||||
)
|
||||
|
||||
# Schedule runs should write labels by default.
|
||||
# Manual runs default to dry-run unless explicitly disabled.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
|
||||
args+=("--dry-run" "--json")
|
||||
fi
|
||||
|
||||
node scripts/issue-labels/index.js "${args[@]}"
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::Issue label sync failed; labels may be stale."
|
||||
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
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 ./...
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -30,3 +30,11 @@ test_scripts/
|
||||
tests/mail/reports/
|
||||
|
||||
/log/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
16
.gitleaks.toml
Normal file
16
.gitleaks.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
title = "lark-cli gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "lark-bot-app-id"
|
||||
description = "Detect Lark bot app ids"
|
||||
regex = '''\bcli_[a-z0-9]{16}\b'''
|
||||
keywords = ["cli_"]
|
||||
|
||||
[[rules]]
|
||||
id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
@@ -27,6 +27,8 @@ linters:
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- depguard # blocks forbidden package imports
|
||||
- forbidigo # forbids specific function calls
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
@@ -44,8 +46,85 @@ linters:
|
||||
linters:
|
||||
- bodyclose
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
shortcuts-no-vfs:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "github.com/larksuite/cli/internal/vfs"
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── 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"
|
||||
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
|
||||
msg: >-
|
||||
internal/: use vfs.CreateTemp() or vfs.OpenFile().
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
- pattern: os\.Mkdir(All)?\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.Remove\b
|
||||
msg: >-
|
||||
internal/: use vfs.Remove() from internal/vfs.
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
- pattern: os\.RemoveAll\b
|
||||
msg: >-
|
||||
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
# ── os: not yet in vfs — add to vfs/fs.go first ──
|
||||
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
|
||||
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
|
||||
# ── os: IO streams ──
|
||||
- pattern: os\.Std(in|out|err)\b
|
||||
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
|
||||
# ── os: process ──
|
||||
- pattern: os\.Exit\b
|
||||
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: >-
|
||||
These filepath functions access the filesystem directly.
|
||||
internal/: use vfs helpers or localfileio path validation.
|
||||
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
|
||||
16
.licenserc.yaml
Normal file
16
.licenserc.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
header:
|
||||
license:
|
||||
content: |
|
||||
Copyright (c) [year] Lark Technologies Pte. Ltd.
|
||||
SPDX-License-Identifier: MIT
|
||||
copyright-year: "2026"
|
||||
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- '**/*.js'
|
||||
- '**/*.py'
|
||||
|
||||
paths-ignore:
|
||||
- '**/testdata/**'
|
||||
|
||||
comment: on-failure
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -1,33 +1,103 @@
|
||||
# AGENTS.md
|
||||
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
|
||||
|
||||
## Goal (pick one per PR)
|
||||
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Fast Dev Loop
|
||||
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
|
||||
2. `make unit-test` (required before PR)
|
||||
3. Run changed command(s) manually via `./lark-cli ...`
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## 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`
|
||||
5. Optional full local suite: `make test` (vet + unit + integration)
|
||||
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`
|
||||
|
||||
## Test/Check Commands
|
||||
- Unit: `make unit-test`
|
||||
- Integration: `make integration-test`
|
||||
- Full: `make test`
|
||||
- Vet only: `make vet`
|
||||
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
|
||||
## Commit & PR
|
||||
|
||||
## Commit/PR Rules
|
||||
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
|
||||
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
|
||||
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
|
||||
- Never commit secrets/tokens/internal sensitive data.
|
||||
- Conventional Commits in English: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:`
|
||||
- PR title in the same format. Fill `.github/pull_request_template.md` completely.
|
||||
- Never commit secrets, tokens, or internal sensitive data.
|
||||
|
||||
## Source Layout
|
||||
|
||||
| Path | What it does |
|
||||
|------|-------------|
|
||||
| `cmd/root.go` | Entry point, command registration, strict mode pruning |
|
||||
| `cmd/profile/` | Multi-profile management (add/list/use/rename/remove) |
|
||||
| `cmd/config/` | Config init, show, strict-mode |
|
||||
| `cmd/service/` | Auto-registered API commands from embedded metadata |
|
||||
| `shortcuts/common/runner.go` | Shortcut execution pipeline, Flag.Input (@file/stdin) resolution |
|
||||
| `shortcuts/` | Domain-specific shortcut implementations |
|
||||
| `internal/cmdutil/factory.go` | Factory pattern — identity resolution, credential, config |
|
||||
| `internal/cmdutil/factory_default.go` | Production factory wiring |
|
||||
| `internal/credential/` | Credential provider chain (extension → default) |
|
||||
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
|
||||
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
|
||||
| `internal/core/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
## Who Uses This CLI
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
|
||||
|
||||
The one rule to internalize: **every error message you write will be parsed by an AI to decide its next action.** Make errors structured, actionable, and specific.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
|
||||
### Validate paths before reading
|
||||
|
||||
CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInputPath` before any file I/O.
|
||||
|
||||
### Tests
|
||||
|
||||
- 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 |
|
||||
|
||||
394
CHANGELOG.md
394
CHANGELOG.md
@@ -2,6 +2,384 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- Improve login scope validation and success output (#317)
|
||||
- **task**: Support starting pagination from page token (#332)
|
||||
- Support multipart doc media uploads (#294)
|
||||
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
|
||||
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve concurrency races in RuntimeContext (#330)
|
||||
- **config**: Save empty config before clearing keychain entries (#291)
|
||||
- Reject positional arguments in shortcuts (#227)
|
||||
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
|
||||
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
|
||||
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
|
||||
- **help**: Point root help Agent Skills link to README section (#289)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate VC/minutes shortcuts to FileIO (#336)
|
||||
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Support multipart upload for files larger than 20MB (#43)
|
||||
- Add darwin file master key fallback for keychain writes (#285)
|
||||
- Add strict mode identity filter, profile management and credential extension (#252)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Restore CID validation and stale PartID lookup lost in revert (#230)
|
||||
- **base**: Clarify table-id `tbl` prefix requirement (#270)
|
||||
- Fix parameter constraints for LarkMessageTrigger (#213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix root calendar example (#299)
|
||||
- Fix README auth scope and api data flag (#298)
|
||||
- Clarify task guid for applinks (#287)
|
||||
- Clarify lark task guid usage (#282)
|
||||
- **lark-base**: Add `has_more` guidance for record-list pagination (#183)
|
||||
|
||||
### Tests
|
||||
|
||||
- Isolate registry package state in tests (#280)
|
||||
|
||||
### CI
|
||||
|
||||
- Add scheduled issue labeler for type/domain triage (#251)
|
||||
- **issue-labels**: Reduce mislabeling and handle missing labels (#288)
|
||||
- Map wiki paths in pr labels (#249)
|
||||
|
||||
## [v1.0.4] - 2026-04-03
|
||||
|
||||
### Features
|
||||
|
||||
- Support user identity for im `+chat-create` (#242)
|
||||
- Implement authentication response logging (#235)
|
||||
- Support im chat member delete and add scope notes (#229)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **security**: Replace `http.DefaultTransport` with proxy-aware base transport to mitigate MITM risk (#247)
|
||||
- **calendar**: Block auto bot fallback without user login (#245)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add identity guidance to prefer user over bot (#157)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **dashboard**: Restructure docs for AI-friendly navigation (#191)
|
||||
|
||||
### CI
|
||||
|
||||
- Add a CLI E2E testing framework for lark-cli, task domain testcase and ci action (#236)
|
||||
|
||||
## [v1.0.3] - 2026-04-02
|
||||
|
||||
### Features
|
||||
|
||||
- Add `--jq` flag for filtering JSON output (#211)
|
||||
- Add `+download` shortcut for minutes media download (#101)
|
||||
- Add drive import, export, move, and task result shortcuts (#194)
|
||||
- Support im message send/reply with uat (#180)
|
||||
- Add approve domain (#217)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
|
||||
- **mail**: On-demand scope checks and watch event filtering (#198)
|
||||
- Use curl for binary download to support proxy and add npmmirror fallback (#226)
|
||||
- Normalize escaped sheet range separators (#207)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Clarify JSON output is directly usable without extra encoding (#228)
|
||||
- Clarify docs search query usage (#221)
|
||||
|
||||
### CI
|
||||
|
||||
- Add gitleaks scanning workflow and custom rules (#142)
|
||||
|
||||
## [v1.0.2] - 2026-04-01
|
||||
|
||||
### Features
|
||||
@@ -110,6 +488,22 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
81
README.md
81
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 19 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, and more, with 200+ commands and 22 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** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 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
|
||||
@@ -22,19 +22,23 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, 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 |
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, 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 |
|
||||
| 📊 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 |
|
||||
| ✅ 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 |
|
||||
| 📈 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 |
|
||||
| 🕐 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 and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -127,27 +131,30 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| 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-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-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `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 |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `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-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `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-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 |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `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-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 |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -171,7 +178,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# Exact scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent mode: return verification URL immediately, non-blocking
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -214,7 +221,7 @@ Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
81
README.zh.md
81
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -22,19 +22,23 @@
|
||||
|
||||
## 功能
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -128,27 +132,30 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
## 认证
|
||||
|
||||
@@ -172,7 +179,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# 精确 scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent 模式:立即返回验证 URL,不阻塞
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -215,7 +222,7 @@ lark-cli calendar events instance_view --params '{"calendar_id":"primary","start
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## 进阶用法
|
||||
|
||||
151
cmd/api/api.go
151
cmd/api/api.go
@@ -5,7 +5,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -40,18 +39,9 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
|
||||
if input == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
|
||||
}
|
||||
return result, nil
|
||||
File string
|
||||
}
|
||||
|
||||
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
|
||||
@@ -67,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
|
||||
|
||||
@@ -87,16 +81,18 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
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)")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
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 {
|
||||
@@ -104,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
|
||||
})
|
||||
|
||||
@@ -115,20 +108,24 @@ 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) {
|
||||
params, err := parseJsonOpt(opts.Params, "--params")
|
||||
// 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
|
||||
|
||||
// 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{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
var data interface{}
|
||||
if opts.Data != "" {
|
||||
data, err = parseJsonOpt(opts.Data, "--data")
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
params["page_size"] = opts.PageSize
|
||||
@@ -138,35 +135,84 @@ 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)
|
||||
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(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
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)
|
||||
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 {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
request, fileMeta, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
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 apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
@@ -184,19 +230,22 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
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.).
|
||||
@@ -210,7 +259,15 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -5,6 +5,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -70,16 +71,6 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-test-token",
|
||||
"expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register API endpoint stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
@@ -189,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,
|
||||
@@ -209,6 +218,22 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both --params and --data use stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -234,13 +259,6 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-bin", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/drive/v1/files/xxx/download",
|
||||
RawBody: []byte("fake-binary-content"),
|
||||
@@ -266,14 +284,6 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a non-batch API that returns scalar data (no array field)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
@@ -310,13 +320,6 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-err", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Non-batch API that returns a business error (code != 0)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
@@ -346,14 +349,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub (unique app credentials => new token request)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a batch API that returns an array field
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
@@ -409,13 +404,6 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-raw", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
@@ -456,13 +444,6 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
@@ -500,18 +481,48 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
@@ -536,6 +547,165 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(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{"GET", "/open-apis/test", "--jq", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_ShortForm(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{"GET", "/open-apis/test", "-q", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndOutputConflict(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", "--jq", ".data", "--output", "file.bin"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
// Should NOT contain the full envelope structure
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndFormatConflict(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", "--jq", ".data", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqInvalidExpression(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", "--jq", "invalid["})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -555,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -48,7 +50,7 @@ type userInfoResponse struct {
|
||||
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/authen/v1/user_info",
|
||||
ApiPath: larkauth.PathUserInfoV1,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
||||
}, larkcore.WithUserAccessToken(accessToken))
|
||||
if err != nil {
|
||||
@@ -99,7 +101,7 @@ type appInfoResponse struct {
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
ac, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("lang", "zh_cn")
|
||||
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/application/v6/applications/" + appId,
|
||||
QueryParams: queryParams,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/registry"
|
||||
)
|
||||
|
||||
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"creator_id": "ou_creator",
|
||||
"scopes": []map[string]interface{}{
|
||||
{
|
||||
"scope": "im:message",
|
||||
"token_types": []string{"tenant"},
|
||||
},
|
||||
{
|
||||
"scope": "im:message:send_as_user",
|
||||
"token_types": []string{"user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(appInfoStub)
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authScopesRun() error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenResolver.requests) != 1 {
|
||||
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
|
||||
}
|
||||
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
|
||||
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
|
||||
}
|
||||
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.TokenResult{Token: "tenant-token"}, nil
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.TokenResult{Token: "user-token"}, nil
|
||||
default:
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func authListRun(opts *ListOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type LoginOptions struct {
|
||||
DeviceCode string
|
||||
}
|
||||
|
||||
var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
@@ -46,6 +48,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
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)
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,6 +61,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
return authLoginRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
@@ -63,7 +72,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
|
||||
})
|
||||
|
||||
@@ -101,8 +110,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
lang := "zh"
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
|
||||
lang = multi.Apps[0].Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
}
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
@@ -123,18 +134,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
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
@@ -235,7 +238,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -252,7 +255,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
@@ -261,20 +264,26 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
@@ -287,6 +296,8 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
|
||||
// Step 7: Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -304,35 +315,16 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": result.Token.Scope,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if result.Token.Scope != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
|
||||
}
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -345,13 +337,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
|
||||
}
|
||||
cleanupRequestedScope := func() {
|
||||
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
@@ -367,6 +372,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
|
||||
// Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -384,26 +391,57 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return fmt.Errorf("profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
if oldUser.UserOpenId != openID {
|
||||
_ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -412,11 +450,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) {
|
||||
@@ -425,7 +468,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)
|
||||
@@ -434,14 +477,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
|
||||
}
|
||||
|
||||
74
cmd/auth/login_config_test.go
Normal file
74
cmd/auth/login_config_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" {
|
||||
t.Fatalf("target users = %#v, want replaced login user", got)
|
||||
}
|
||||
if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" {
|
||||
t.Fatalf("other users = %#v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing profile")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `profile "missing" not found`) {
|
||||
t.Fatalf("error = %v, want missing profile", err)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -20,11 +20,17 @@ type loginMsg struct {
|
||||
ConfirmAuth string
|
||||
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
GrantedScopes string
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
// Non-interactive hint (no flags)
|
||||
HintHeader string
|
||||
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
|
||||
ErrNoDomain: "请至少选择一个业务域",
|
||||
ConfirmAuth: "确认授权?",
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
GrantedScopes: " 已授权 scopes: %s\n",
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
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",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
HintHeader: "请指定要授权的权限:\n",
|
||||
HintCommon1: " --recommend 授权推荐权限",
|
||||
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
|
||||
ErrNoDomain: "please select at least one domain",
|
||||
ConfirmAuth: "Confirm authorization?",
|
||||
|
||||
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)",
|
||||
GrantedScopes: " Granted scopes: %s\n",
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
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",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
HintHeader: "Please specify the scopes to authorize:\n",
|
||||
HintCommon1: " --recommend authorize recommended scopes",
|
||||
|
||||
@@ -69,10 +69,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// GrantedScopes should contain %s
|
||||
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
|
||||
if got == msg.GrantedScopes {
|
||||
t.Errorf("%s GrantedScopes 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
|
||||
|
||||
234
cmd/auth/login_result.go
Normal file
234
cmd/auth/login_result.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type loginScopeSummary struct {
|
||||
Requested []string
|
||||
NewlyGranted []string
|
||||
AlreadyGranted []string
|
||||
Granted []string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
type loginScopeIssue struct {
|
||||
Message string
|
||||
Hint string
|
||||
Summary *loginScopeSummary
|
||||
}
|
||||
|
||||
// ensureRequestedScopesGranted checks whether all requested scopes were granted
|
||||
// and returns a structured issue when any requested scope is missing.
|
||||
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := larkauth.MissingScopes(grantedScope, requested)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: strings.Fields(grantedScope),
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
return &loginScopeIssue{
|
||||
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
|
||||
Hint: msg.ScopeHint,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
|
||||
// previously stored scopes, and the newly granted scopes from the current login.
|
||||
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
|
||||
previousScope := ""
|
||||
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
|
||||
previousScope = previous.Scope
|
||||
}
|
||||
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
|
||||
}
|
||||
|
||||
// buildLoginScopeSummary classifies requested scopes into newly granted,
|
||||
// already granted, and missing buckets while preserving the final granted list.
|
||||
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
previous := uniqueScopeList(previousScope)
|
||||
granted := uniqueScopeList(grantedScope)
|
||||
previousSet := make(map[string]bool, len(previous))
|
||||
for _, scope := range previous {
|
||||
previousSet[scope] = true
|
||||
}
|
||||
grantedSet := make(map[string]bool, len(granted))
|
||||
for _, scope := range granted {
|
||||
grantedSet[scope] = true
|
||||
}
|
||||
|
||||
summary := &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
}
|
||||
for _, scope := range requested {
|
||||
if !grantedSet[scope] {
|
||||
summary.Missing = append(summary.Missing, scope)
|
||||
continue
|
||||
}
|
||||
if previousSet[scope] {
|
||||
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
|
||||
continue
|
||||
}
|
||||
summary.NewlyGranted = append(summary.NewlyGranted, scope)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
|
||||
func uniqueScopeList(scope string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, item := range strings.Fields(scope) {
|
||||
if seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatScopeList joins scopes for display and falls back to the provided empty
|
||||
// label when the input slice is empty.
|
||||
func formatScopeList(scopes []string, empty string) string {
|
||||
if len(scopes) == 0 {
|
||||
return empty
|
||||
}
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
|
||||
func emptyIfNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
// format together with the computed scope breakdown.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
|
||||
if len(summary.Missing) == 0 && msg.StatusHint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
||||
// while preserving a successful login outcome when authorization completed.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
||||
if issue == nil {
|
||||
return nil
|
||||
}
|
||||
loginSucceeded := openId != ""
|
||||
if opts.JSON {
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_scope",
|
||||
Message: issue.Message,
|
||||
Hint: issue.Hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
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)
|
||||
}
|
||||
|
||||
// authorizationCompletePayload builds the JSON payload for a completed login,
|
||||
// optionally attaching a warning when requested scopes are missing.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": strings.Join(summary.Granted, " "),
|
||||
"requested": emptyIfNil(summary.Requested),
|
||||
"newly_granted": emptyIfNil(summary.NewlyGranted),
|
||||
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
||||
"missing": emptyIfNil(summary.Missing),
|
||||
"granted": emptyIfNil(summary.Granted),
|
||||
}
|
||||
if issue != nil {
|
||||
payload["warning"] = map[string]interface{}{
|
||||
"type": "missing_scope",
|
||||
"message": issue.Message,
|
||||
"hint": issue.Hint,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
94
cmd/auth/login_scope_cache.go
Normal file
94
cmd/auth/login_scope_cache.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
type loginScopeCacheRecord struct {
|
||||
RequestedScope string `json:"requested_scope"`
|
||||
}
|
||||
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
func loginScopeCachePath(deviceCode string) string {
|
||||
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
|
||||
func sanitizeLoginScopeCacheKey(deviceCode string) string {
|
||||
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
|
||||
if sanitized == "" {
|
||||
return "default"
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// saveLoginRequestedScope persists the requested scope string for a device_code.
|
||||
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
|
||||
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
|
||||
// It returns an empty string if no cache entry exists.
|
||||
func loadLoginRequestedScope(deviceCode string) (string, error) {
|
||||
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
var record loginScopeCacheRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
_ = vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
return "", err
|
||||
}
|
||||
return record.RequestedScope, nil
|
||||
}
|
||||
|
||||
// removeLoginRequestedScope deletes the cache entry for a device_code.
|
||||
func removeLoginRequestedScope(deviceCode string) error {
|
||||
err := vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
|
||||
// should be removed after polling finishes.
|
||||
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if result.OK || result.Error == "access_denied" {
|
||||
return true
|
||||
}
|
||||
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
|
||||
}
|
||||
51
cmd/auth/login_scope_cache_test.go
Normal file
51
cmd/auth/login_scope_cache_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
deviceCode := "device/code:123"
|
||||
requestedScope := "im:message:send im:message:reply"
|
||||
|
||||
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
got, err := loadLoginRequestedScope(deviceCode)
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != requestedScope {
|
||||
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
|
||||
t.Fatalf("Stat(cachePath) error = %v", err)
|
||||
}
|
||||
if err := removeLoginRequestedScope(deviceCode); err != nil {
|
||||
t.Fatalf("removeLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
got, err := loadLoginRequestedScope("missing-device-code")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("requestedScope = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
81
cmd/auth/login_strict_test.go
Normal file
81
cmd/auth/login_strict_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if called {
|
||||
t.Error("runF should not be called in bot strict mode")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error in bot strict mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called in user strict mode")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called when strict mode is off")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,29 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
|
||||
func TestSuggestDomain_PrefixMatch(t *testing.T) {
|
||||
known := map[string]bool{
|
||||
"calendar": true,
|
||||
@@ -282,6 +295,615 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequestedScopesGranted(t *testing.T) {
|
||||
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
|
||||
if issue == nil {
|
||||
t.Fatal("expected missing scope issue")
|
||||
}
|
||||
if !strings.Contains(issue.Message, "im:message:send") {
|
||||
t.Fatalf("message %q missing requested scope", issue.Message)
|
||||
}
|
||||
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
|
||||
if !strings.Contains(issue.Hint, want) {
|
||||
t.Fatalf("hint %q missing %q", issue.Hint, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
|
||||
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
|
||||
t.Fatalf("Requested = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
|
||||
t.Fatalf("NewlyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if len(summary.Missing) != 0 {
|
||||
t.Fatalf("Missing = %v, want empty", summary.Missing)
|
||||
}
|
||||
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
|
||||
t.Fatalf("Granted = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["scope"] != "im:message:send im:message:reply" {
|
||||
t.Fatalf("scope = %v", data["scope"])
|
||||
}
|
||||
if len(data["newly_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("newly_granted = %#v", data["newly_granted"])
|
||||
}
|
||||
if len(data["already_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("already_granted = %#v", data["already_granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, 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 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"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["user_open_id"] != "ou_user" {
|
||||
t.Fatalf("user_open_id = %v", data["user_open_id"])
|
||||
}
|
||||
warning, ok := data["warning"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("warning = %#v", data["warning"])
|
||||
}
|
||||
if warning["type"] != "missing_scope" {
|
||||
t.Fatalf("warning.type = %v", warning["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Granted: []string{"offline_access"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %q in payload: %v", k, data)
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
t.Fatalf("%s = %#v, want JSON array", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
summary *loginScopeSummary
|
||||
expectedPresent []string
|
||||
expectedAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "mixed newly granted and already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send", "contact:user.base:readonly"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scopes are shown",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range tt.expectedPresent {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.expectedAbsent {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
|
||||
t.Fatalf("NewlyGranted = %q, want empty", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
stored := larkauth.GetStoredToken("cli_test", "ou_user")
|
||||
if stored == nil {
|
||||
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
|
||||
}
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
|
||||
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
|
||||
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserName != "tester" {
|
||||
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "im:message:send offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no-wait authLoginRun() error = %v", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
|
||||
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
err = authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("device-code authLoginRun() error = %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send"},
|
||||
})
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"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) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
|
||||
t.Fatalf("error = %v, want nil token error", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
@@ -290,3 +912,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := &multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
30
cmd/bootstrap.go
Normal file
30
cmd/bootstrap.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BootstrapInvocationContext extracts global invocation options before
|
||||
// the real command tree is built, so provider-backed config resolution sees
|
||||
// the correct profile from the start.
|
||||
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
||||
var globals GlobalOptions
|
||||
|
||||
fs := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.SetInterspersed(true)
|
||||
fs.SetOutput(io.Discard)
|
||||
RegisterGlobalFlags(fs, &globals)
|
||||
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
72
cmd/bootstrap_test.go
Normal file
72
cmd/bootstrap_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileEquals(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--profile=target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_IgnoresUnknownFlags(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--verify", "--profile", "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_MissingProfileValue(t *testing.T) {
|
||||
if _, err := BootstrapInvocationContext([]string{"auth", "status", "--profile"}); err == nil {
|
||||
t.Fatal("BootstrapInvocationContext() error = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ShortHelp(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("-h should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--profile + --help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
129
cmd/build.go
Normal file
129
cmd/build.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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"
|
||||
"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/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
|
||||
}
|
||||
|
||||
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))
|
||||
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")
|
||||
}
|
||||
}
|
||||
586
cmd/config/bind.go
Normal file
586
cmd/config/bind.go
Normal file
@@ -0,0 +1,586 @@
|
||||
// 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) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
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); 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)
|
||||
|
||||
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" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
|
||||
}
|
||||
|
||||
var detected string
|
||||
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case core.WorkspaceOpenClaw:
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
}
|
||||
|
||||
// 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, or run this command inside an OpenClaw or Hermes chat")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
}
|
||||
|
||||
// Resolve actual paths for display
|
||||
openclawPath := resolveOpenClawConfigPath()
|
||||
hermesEnvPath := resolveHermesEnvPath()
|
||||
|
||||
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"),
|
||||
).
|
||||
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
|
||||
}
|
||||
172
cmd/config/bind_messages.go
Normal file
172
cmd/config/bind_messages.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// 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.
|
||||
|
||||
// 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",
|
||||
|
||||
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",
|
||||
|
||||
// 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 "飞书"
|
||||
}
|
||||
1400
cmd/config/bind_test.go
Normal file
1400
cmd/config/bind_test.go
Normal file
File diff suppressed because it is too large
Load Diff
414
cmd/config/binder.go
Normal file
414
cmd/config/binder.go
Normal file
@@ -0,0 +1,414 @@
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 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"
|
||||
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")
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
@@ -18,15 +18,14 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
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))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
if value == "lark" {
|
||||
return core.BrandLark
|
||||
}
|
||||
return core.BrandFeishu
|
||||
return core.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,35 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
|
||||
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
type recordingConfigKeychain struct {
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
r.removed = append(r.removed, service+":"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -56,6 +78,60 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -157,3 +233,110 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
t.Fatal("expected factory to be preserved in options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
kc := &recordingConfigKeychain{}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Keychain = kc
|
||||
|
||||
// Make subsequent config saves fail while keeping the existing config readable.
|
||||
if err := os.Chmod(configDir, 0500); err != nil {
|
||||
t.Fatalf("Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
defer os.Chmod(configDir, 0700)
|
||||
|
||||
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected save failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to save config") {
|
||||
t.Fatalf("error = %v, want failed to save config", err)
|
||||
}
|
||||
if len(kc.removed) != 0 {
|
||||
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
|
||||
}
|
||||
|
||||
// Restore permissions and confirm the original config is still intact.
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
|
||||
t.Fatalf("saved config = %#v, want original single app preserved", saved)
|
||||
}
|
||||
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
|
||||
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Fatalf("expected existing config file to remain, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Lang: "zh",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "App Secret") {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
current := multi.Apps[0].DefaultAs
|
||||
current := app.DefaultAs
|
||||
if current == "" {
|
||||
current = "auto"
|
||||
}
|
||||
@@ -39,9 +44,9 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
multi.Apps[0].DefaultAs = value
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,8 @@ type ConfigInitOptions struct {
|
||||
Brand string
|
||||
New bool
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -59,6 +62,7 @@ verification URL from its output.`,
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
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)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -94,6 +98,110 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
return core.SaveMultiAppConfig(config)
|
||||
}
|
||||
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
|
||||
// Clean up old keychain secret and user tokens if AppId changed
|
||||
if multi.Apps[idx].AppId != appId {
|
||||
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
for _, user := range multi.Apps[idx].Users {
|
||||
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = lang
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].Name == profileName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].AppId == appID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = lang
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -117,6 +225,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -124,8 +239,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, opts.AppID)
|
||||
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -136,8 +250,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
savedLang := ""
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
savedLang = existing.Apps[0].Lang
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
if err != nil {
|
||||
@@ -165,8 +281,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -191,21 +306,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
existing.Apps[0].AppId = result.AppID
|
||||
existing.Apps[0].Brand = result.Brand
|
||||
existing.Apps[0].Lang = opts.Lang
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
@@ -224,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(f.IOStreams.In)
|
||||
@@ -296,8 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, resolvedAppId)
|
||||
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -61,8 +61,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
// Load existing config for defaults
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
var appID, appSecret, brand string
|
||||
@@ -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,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
return output.ErrValidation("not configured yet")
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
// Save empty config
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
|
||||
userCount := 0
|
||||
for _, app := range config.Apps {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -40,12 +42,19 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
|
||||
return nil
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return 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")
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
}
|
||||
app := config.Apps[0]
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
var userStrs []string
|
||||
@@ -55,6 +64,8 @@ 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": "****",
|
||||
"brand": app.Brand,
|
||||
@@ -64,3 +75,18 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
146
cmd/config/strict_mode.go
Normal file
146
cmd/config/strict_mode.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
|
||||
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
var global bool
|
||||
var reset bool
|
||||
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
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)
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
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 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 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 setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
|
||||
// Runtime effective mode from credential provider chain is the source of truth.
|
||||
runtime := f.ResolveStrictMode(ctx)
|
||||
configMode, configSource := resolveStrictModeStatus(multi, app)
|
||||
|
||||
if runtime != configMode {
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
if a.StrictMode != nil && *a.StrictMode != mode {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut,
|
||||
"Warning: profile %q has strict-mode explicitly set to %q, "+
|
||||
"which overrides the global setting. "+
|
||||
"Use --reset in that profile to inherit global.\n",
|
||||
a.ProfileName(), *a.StrictMode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
if multi.StrictMode.IsActive() {
|
||||
return multi.StrictMode, "global"
|
||||
}
|
||||
return core.StrictModeOff, "global (default)"
|
||||
}
|
||||
164
cmd/config/strict_mode_test.go
Normal file
164
cmd/config/strict_mode_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Show_Default(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "off") {
|
||||
t.Errorf("expected 'off' in output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected StrictMode=bot on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetUser_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"user"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
|
||||
t.Error("expected StrictMode=user on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
|
||||
t.Error("expected StrictMode=off on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != core.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Reset(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"--reset"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid value 'on'")
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -238,7 +238,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 +253,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 {
|
||||
|
||||
40
cmd/global_flags.go
Normal file
40
cmd/global_flags.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
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. 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
|
||||
HideProfile bool
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
137
cmd/profile/add.go
Normal file
137
cmd/profile/add.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
appID string
|
||||
appSecretStdin bool
|
||||
brand string
|
||||
lang string
|
||||
use bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileAddRun(f, name, appID, appSecretStdin, brand, lang, use)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "profile name (required)")
|
||||
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
||||
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
||||
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return output.ErrValidation("profile %q already exists", name)
|
||||
}
|
||||
|
||||
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||
// multiple profiles sharing the same appId would collide on credentials.
|
||||
for _, a := range multi.Apps {
|
||||
if a.AppId == appID {
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
if useAfter {
|
||||
if currentApp := multi.CurrentAppConfig(""); currentApp != nil {
|
||||
previousName = currentApp.ProfileName()
|
||||
}
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
if previousName != "" {
|
||||
multi.PreviousApp = previousName
|
||||
}
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
if useAfter {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q", name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
cmd/profile/list.go
Normal file
85
cmd/profile/list.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
currentName = currentApp.ProfileName()
|
||||
}
|
||||
|
||||
items := make([]profileListItem, 0, len(multi.Apps))
|
||||
for i := range multi.Apps {
|
||||
app := &multi.Apps[i]
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
item.User = app.Users[0].UserName
|
||||
stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId)
|
||||
if stored != nil {
|
||||
item.TokenStatus = larkauth.TokenStatus(stored)
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, items)
|
||||
return nil
|
||||
}
|
||||
29
cmd/profile/profile.go
Normal file
29
cmd/profile/profile.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
|
||||
})
|
||||
|
||||
cmd.AddCommand(NewCmdProfileList(f))
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
cmd.AddCommand(NewCmdProfileRename(f))
|
||||
return cmd
|
||||
}
|
||||
371
cmd/profile/profile_test.go
Normal file
371
cmd/profile/profile_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type failRenameFS struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
}
|
||||
|
||||
func (fs *failRenameFS) Rename(oldpath, newpath string) error {
|
||||
return fs.err
|
||||
}
|
||||
|
||||
func setupProfileConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
|
||||
err := profileAddRun(f, "test", "app-test", true, "feishu", "zh", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid existing config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to load config") {
|
||||
t.Fatalf("error = %v, want failed to load config", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret-new\n")
|
||||
|
||||
if err := profileAddRun(f, "target", "app-target", true, "lark", "en", true); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 2 {
|
||||
t.Fatalf("len(Apps) = %d, want 2", len(saved.Apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
PreviousApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRemoveRun(f, "target"); err != nil {
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "default" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" {
|
||||
t.Fatalf("remaining apps = %#v, want only default", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "new"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "new" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "new")
|
||||
}
|
||||
if saved.PreviousApp != "new" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "new")
|
||||
}
|
||||
if saved.Apps[0].ProfileName() != "new" {
|
||||
t.Fatalf("ProfileName() = %q, want %q", saved.Apps[0].ProfileName(), "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "app-old"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "app-old" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "app-old")
|
||||
}
|
||||
if saved.PreviousApp != "app-old" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "app-old")
|
||||
}
|
||||
if saved.Apps[0].Name != "app-old" {
|
||||
t.Fatalf("Name = %q, want %q", saved.Apps[0].Name, "app-old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
PreviousApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileUseRun(f, "-"); err != nil {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len(got) = %d, want 0", len(got))
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "old", "new")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
}
|
||||
}
|
||||
78
cmd/profile/remove.go
Normal file
78
cmd/profile/remove.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRemoveRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
removedName := app.ProfileName()
|
||||
appId := app.AppId
|
||||
appSecret := app.AppSecret
|
||||
users := app.Users
|
||||
|
||||
// Remove from slice
|
||||
multi.Apps = append(multi.Apps[:idx], multi.Apps[idx+1:]...)
|
||||
|
||||
// Fix currentApp / previousApp references
|
||||
if multi.CurrentApp == removedName {
|
||||
multi.CurrentApp = multi.Apps[0].ProfileName()
|
||||
}
|
||||
if multi.PreviousApp == removedName {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/rename.go
Normal file
73
cmd/profile/rename.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRename creates the profile rename subcommand.
|
||||
func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename <old> <new>",
|
||||
Short: "Rename a profile",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
// profile's own appId or current name.
|
||||
for i := range multi.Apps {
|
||||
if i == idx {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
oldProfileName := multi.Apps[idx].ProfileName()
|
||||
multi.Apps[idx].Name = newName
|
||||
|
||||
// Update currentApp / previousApp references
|
||||
if multi.CurrentApp == oldProfileName {
|
||||
multi.CurrentApp = newName
|
||||
}
|
||||
if multi.PreviousApp == oldProfileName {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/use.go
Normal file
73
cmd/profile/use.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileUse creates the profile use subcommand.
|
||||
func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Switch to a profile (use '-' to toggle back)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileUseRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
|
||||
// Short-circuit if already on the target profile
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
if currentApp != nil && currentApp.ProfileName() == targetName {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Already on profile %q\n", targetName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update previous and current
|
||||
if currentApp != nil {
|
||||
multi.PreviousApp = currentApp.ProfileName()
|
||||
}
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||
return nil
|
||||
}
|
||||
80
cmd/prune.go
Normal file
80
cmd/prune.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
|
||||
// pruneIncompatible recursively replaces commands whose annotation declares
|
||||
// identities incompatible with the forced identity. Commands without annotation are kept.
|
||||
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
||||
// of Cobra's generic "unknown flag" fallback from the parent command.
|
||||
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
ids := cmdutil.GetSupportedIdentities(child)
|
||||
if ids != nil && !slices.Contains(ids, forced) {
|
||||
toRemove = append(toRemove, child)
|
||||
toAdd = append(toAdd, strictModeStubFrom(child, mode))
|
||||
continue
|
||||
}
|
||||
pruneIncompatible(child, mode)
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
parent.AddCommand(toAdd...)
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmpty recursively removes group commands (no Run/RunE) that have
|
||||
// no remaining subcommands after pruning. If only hidden stubs remain, keep
|
||||
// the group hidden so direct execution still resolves to the stub path.
|
||||
func pruneEmpty(parent *cobra.Command) {
|
||||
var toRemove []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
pruneEmpty(child)
|
||||
if child.Run != nil || child.RunE != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case child.HasAvailableSubCommands():
|
||||
case len(child.Commands()) > 0:
|
||||
child.Hidden = true
|
||||
default:
|
||||
toRemove = append(toRemove, child)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
}
|
||||
}
|
||||
200
cmd/prune_test.go
Normal file
200
cmd/prune_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
|
||||
noop := func(*cobra.Command, []string) error { return nil }
|
||||
|
||||
userOnly := &cobra.Command{Use: "+search", Short: "user only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
botOnly := &cobra.Command{Use: "+subscribe", Short: "bot only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
|
||||
svc.AddCommand(botOnly)
|
||||
|
||||
dual := &cobra.Command{Use: "+send", Short: "dual", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(dual, []string{"user", "bot"})
|
||||
svc.AddCommand(dual)
|
||||
|
||||
noAnnotation := &cobra.Command{Use: "+legacy", Short: "no annotation", RunE: noop}
|
||||
svc.AddCommand(noAnnotation)
|
||||
|
||||
res := &cobra.Command{Use: "messages"}
|
||||
svc.AddCommand(res)
|
||||
userMethod := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmdutil.SetSupportedIdentities(userMethod, []string{"user"})
|
||||
res.AddCommand(userMethod)
|
||||
|
||||
auth := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(auth)
|
||||
login := &cobra.Command{Use: "login", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(login, []string{"user"})
|
||||
auth.AddCommand(login)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func findCmd(root *cobra.Command, names ...string) *cobra.Command {
|
||||
cmd := root
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
cmd = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+subscribe") == nil {
|
||||
t.Error("+subscribe (bot-only) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+legacy") == nil {
|
||||
t.Error("+legacy (no annotation) should be kept")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "messages", "search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("search (user-only method) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("auth login should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
|
||||
if findCmd(root, "im", "+search") == nil {
|
||||
t.Error("+search (user-only) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "+subscribe"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+subscribe (bot-only) should be replaced by a hidden stub in user mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || cmd.Hidden {
|
||||
t.Error("auth login should be kept in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty_PreservesOriginallyHiddenGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
hidden := &cobra.Command{Use: "hidden", Hidden: true}
|
||||
root.AddCommand(hidden)
|
||||
hidden.AddCommand(&cobra.Command{
|
||||
Use: "visible",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
})
|
||||
|
||||
pruneEmpty(root)
|
||||
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("expected originally hidden group to remain hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "+search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "user"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
62
cmd/root.go
62
cmd/root.go
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,13 +14,6 @@ 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/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"
|
||||
@@ -27,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -43,7 +36,7 @@ EXAMPLES:
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events list --params '{"calendar_id":"primary"}'
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
@@ -61,6 +54,8 @@ FLAGS:
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
@@ -74,7 +69,7 @@ AI AGENT SKILLS:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
@@ -85,28 +80,18 @@ More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
f := cmdutil.NewDefault()
|
||||
|
||||
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
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(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)
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -170,6 +155,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.SetFlagCompletionsDisabled(!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 {
|
||||
@@ -257,10 +248,19 @@ 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)
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"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/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildTestRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeE2E runs a command through the full command tree and handleRootError,
|
||||
// returning exit code — matching real CLI behavior.
|
||||
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
|
||||
func registerTokenStub(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-e2e-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
492
cmd/root_integration_test.go
Normal file
492
cmd/root_integration_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"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/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeRootIntegration runs a command through the full command tree and
|
||||
// handleRootError, returning the exit code matching real CLI behavior.
|
||||
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
targetMode := mode
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
StrictMode: &targetMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f := cmdutil.NewDefault(
|
||||
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
||||
cmdutil.InvocationContext{Profile: profile},
|
||||
)
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stdout.Reset()
|
||||
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) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("auth --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "login") {
|
||||
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
resetBuffers(stdout, stderr)
|
||||
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
|
||||
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("im --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "+messages-search") {
|
||||
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+chat-create") {
|
||||
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
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.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
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.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
||||
// +chat-create supports both user and bot identities, so strict mode user
|
||||
// should allow it and force user identity.
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty stdout for dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
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.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -187,3 +187,37 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
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.SetFlagCompletionsDisabled(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.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -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,23 +109,35 @@ type ServiceMethodOptions struct {
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format 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")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
@@ -146,34 +166,48 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
}
|
||||
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)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
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.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"))
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
@@ -185,8 +219,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,17 +232,20 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -223,7 +263,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -232,34 +272,42 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
}
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
result, err := cred.ResolveToken(ctx, credential.NewTokenSpec(identity, config.AppID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored != nil {
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
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, " ")))
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
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, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -268,16 +316,12 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// Default: ANY one of the declared scopes is sufficient
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
grantedScopes := make(map[string]bool)
|
||||
for _, s := range strings.Fields(stored.Scope) {
|
||||
grantedScopes[s] = true
|
||||
grantedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedScopes[str] {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -288,19 +332,28 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// 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
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
|
||||
var params map[string]interface{}
|
||||
if opts.Params != "" {
|
||||
if err := json.Unmarshal([]byte(opts.Params), ¶ms); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
|
||||
}
|
||||
} else {
|
||||
params = map[string]interface{}{}
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
@@ -313,13 +366,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)
|
||||
@@ -335,7 +388,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))
|
||||
}
|
||||
@@ -349,22 +402,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
}
|
||||
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
|
||||
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)
|
||||
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(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
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)
|
||||
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 {
|
||||
@@ -400,7 +491,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -44,16 +45,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
return m
|
||||
}
|
||||
|
||||
func tokenStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: "tenant_access_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test", "expire": 7200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
|
||||
func TestRegisterService(t *testing.T) {
|
||||
@@ -130,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) {
|
||||
@@ -318,7 +327,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--params invalid JSON format") {
|
||||
if !strings.Contains(err.Error(), "--params invalid format") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -341,6 +350,24 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both --params and --data use stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
@@ -364,7 +391,6 @@ func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -391,7 +417,6 @@ func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
@@ -425,7 +450,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -455,7 +479,6 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
@@ -474,6 +497,171 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── jq flag ──
|
||||
|
||||
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--jq", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("runF was not called")
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"-q", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
@@ -541,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 {
|
||||
|
||||
314
cmd/update/update.go
Normal file
314
cmd/update/update.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// 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/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
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 }
|
||||
|
||||
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) {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.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),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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 {
|
||||
output.PrintJson(io.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(),
|
||||
})
|
||||
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) error {
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.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(),
|
||||
})
|
||||
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)
|
||||
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
|
||||
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).
|
||||
skillsResult := updater.RunSkillsUpdate()
|
||||
|
||||
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(),
|
||||
}
|
||||
if skillsResult.Err != nil {
|
||||
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
}
|
||||
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())
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
if skillsResult.Err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
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))
|
||||
}
|
||||
851
cmd/update/update_test.go
Normal file
851
cmd/update/update_test.go
Normal file
@@ -0,0 +1,851 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "already_up_to_date"`) {
|
||||
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "already up to date") {
|
||||
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected accurate reason in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--force", "--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
err := cmd.Execute()
|
||||
// cobra silences errors when RunE returns; we just check stdout
|
||||
_ = err
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("expected ok:false in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "network timeout") {
|
||||
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_Human(t *testing.T) {
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
// Suppress cobra's default error printing.
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "not-a-version", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "invalid version") {
|
||||
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "DEV" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "permission denied") {
|
||||
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"hint"`) {
|
||||
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
_ = cmd.Execute()
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update failed") {
|
||||
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Permission denied") {
|
||||
t.Errorf("expected permission hint in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "automatic rollback is unavailable") {
|
||||
t.Errorf("expected unavailable rollback hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "previous version has been restored") {
|
||||
t.Errorf("should not claim restore when no backup is available, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "update_available"`) {
|
||||
t.Errorf("expected update_available action, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true for npm, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned release URL, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "CHANGELOG") {
|
||||
t.Errorf("expected changelog URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Npm(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update") {
|
||||
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Manual(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "manually") {
|
||||
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "lark-cli update` to install") {
|
||||
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
// npm detected (node_modules in path) but npm binary not available
|
||||
mockDetect(t, selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm,
|
||||
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
|
||||
NpmAvailable: false,
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required when npm not found, got: %s", out)
|
||||
}
|
||||
// Must say "npm is not available", not generic "not installed via npm"
|
||||
if !strings.Contains(out, "npm is not available") {
|
||||
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseURL(t *testing.T) {
|
||||
got := releaseURL("2.0.0")
|
||||
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
|
||||
t.Errorf("expected version-pinned URL, got: %s", got)
|
||||
}
|
||||
got2 := releaseURL("v1.5.0")
|
||||
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
|
||||
t.Errorf("expected no double v prefix, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
if strings.Contains(hint, "sudo npm install -g") {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Check_JSON(t *testing.T) {
|
||||
// --check on Windows npm should report auto_update: true (rename trick available).
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Symbols(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
currentOS = "windows"
|
||||
if symOK() != "[OK]" {
|
||||
t.Errorf("expected [OK] on Windows, got: %s", symOK())
|
||||
}
|
||||
if symFail() != "[FAIL]" {
|
||||
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
|
||||
}
|
||||
if symWarn() != "[WARN]" {
|
||||
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
|
||||
}
|
||||
if symArrow() != "->" {
|
||||
t.Errorf("expected -> on Windows, got: %s", symArrow())
|
||||
}
|
||||
|
||||
currentOS = "darwin"
|
||||
if symOK() != "\u2713" {
|
||||
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
|
||||
}
|
||||
if symArrow() != "\u2192" {
|
||||
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Should NOT have skills_warning when skills succeed
|
||||
if strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected no skills_warning on success, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// CLI update should still succeed (ok:true)
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true despite skills failure, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected action:updated despite skills failure, got: %s", out)
|
||||
}
|
||||
// Should have skills_warning with detail
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
// CLI update should still show success
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected CLI success message, got: %s", out)
|
||||
}
|
||||
// Skills warning should be shown
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
if len(got) != 2000 {
|
||||
t.Errorf("expected truncated length 2000, got %d", len(got))
|
||||
}
|
||||
|
||||
short := "hello"
|
||||
got2 := selfupdate.Truncate(short, 2000)
|
||||
if got2 != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", got2)
|
||||
}
|
||||
}
|
||||
2511
coverage.txt
Normal file
2511
coverage.txt
Normal file
File diff suppressed because it is too large
Load Diff
56
download.html
Normal file
56
download.html
Normal file
File diff suppressed because one or more lines are too long
28
extension/contentsafety/registry.go
Normal file
28
extension/contentsafety/registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register installs a content-safety Provider. Later registrations
|
||||
// override earlier ones (last-write-wins).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
29
extension/contentsafety/types.go
Normal file
29
extension/contentsafety/types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Provider scans parsed response data for content-safety issues.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
|
||||
}
|
||||
|
||||
// ScanRequest carries the data to scan.
|
||||
type ScanRequest struct {
|
||||
Path string // normalized command path (e.g. "im.messages_search")
|
||||
Data any // parsed response data (generic JSON shape)
|
||||
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
|
||||
}
|
||||
|
||||
// Alert holds the result of a content-safety scan that detected issues.
|
||||
type Alert struct {
|
||||
Provider string `json:"provider"`
|
||||
MatchedRules []string `json:"matched_rules"`
|
||||
}
|
||||
70
extension/contentsafety/types_test.go
Normal file
70
extension/contentsafety/types_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertFields(t *testing.T) {
|
||||
a := &Alert{
|
||||
Provider: "regex",
|
||||
MatchedRules: []string{"rule_a", "rule_b"},
|
||||
}
|
||||
if a.Provider != "regex" {
|
||||
t.Errorf("Provider = %q, want %q", a.Provider, "regex")
|
||||
}
|
||||
if len(a.MatchedRules) != 2 {
|
||||
t.Errorf("MatchedRules length = %d, want 2", len(a.MatchedRules))
|
||||
}
|
||||
}
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
func (s *stubProvider) Name() string { return "stub" }
|
||||
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
|
||||
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
|
||||
}
|
||||
|
||||
func TestProviderInterface(t *testing.T) {
|
||||
var p Provider = &stubProvider{}
|
||||
if p.Name() != "stub" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "stub")
|
||||
}
|
||||
alert, err := p.Scan(context.Background(), ScanRequest{Path: "test", Data: nil, ErrOut: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan() error = %v", err)
|
||||
}
|
||||
if alert.Provider != "stub" {
|
||||
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLastWriteWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := provider
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
provider = old
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
if GetProvider() != nil {
|
||||
t.Fatal("expected nil provider initially")
|
||||
}
|
||||
p1 := &stubProvider{}
|
||||
Register(p1)
|
||||
if GetProvider() != p1 {
|
||||
t.Fatal("expected p1 after first Register")
|
||||
}
|
||||
p2 := &stubProvider{}
|
||||
Register(p2)
|
||||
if GetProvider() != p2 {
|
||||
t.Fatal("expected p2 after second Register (last-write-wins)")
|
||||
}
|
||||
}
|
||||
116
extension/credential/env/env.go
vendored
Normal file
116
extension/credential/env/env.go
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Provider resolves credentials from environment variables.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "env" }
|
||||
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
acct.DefaultAs = credential.IdentityUser
|
||||
case hasTAT:
|
||||
acct.DefaultAs = credential.IdentityBot
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
envKey = envvars.CliUserAccessToken
|
||||
case credential.TokenTypeTAT:
|
||||
envKey = envvars.CliTenantAccessToken
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
token := os.Getenv(envKey)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
282
extension/credential/env/env_test.go
vendored
Normal file
282
extension/credential/env/env_test.go
vendored
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
if (&Provider{}).Name() != "env" {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, "feishu")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_NeitherSet(t *testing.T) {
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil || acct != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected account, got nil")
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
|
||||
}
|
||||
if acct.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultBrand(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
acct, _ := (&Provider{}).ResolveAccount(context.Background())
|
||||
if acct.Brand != "feishu" {
|
||||
t.Errorf("expected 'feishu', got %q", acct.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_UATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_TATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotSet(t *testing.T) {
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil || tok != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeBot(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeUser(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOff(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "off")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "bot" {
|
||||
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferBothTokens(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid strict mode")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid default-as")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
48
extension/credential/registry.go
Normal file
48
extension/credential/registry.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
providers []Provider
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
sort.SliceStable(providers, func(i, j int) bool {
|
||||
return providerPriority(providers[i]) < providerPriority(providers[j])
|
||||
})
|
||||
}
|
||||
|
||||
// providerPriority returns the priority of a provider.
|
||||
// If the provider implements interface{ Priority() int }, that value is used;
|
||||
// otherwise 10 is returned as the default priority.
|
||||
// Lower values are consulted first.
|
||||
func providerPriority(p Provider) int {
|
||||
if pp, ok := p.(interface{ Priority() int }); ok {
|
||||
return pp.Priority()
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
func Providers() []Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]Provider, len(providers))
|
||||
copy(result, providers)
|
||||
return result
|
||||
}
|
||||
80
extension/credential/registry_test.go
Normal file
80
extension/credential/registry_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ name string }
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return &Account{AppID: s.name}, nil
|
||||
}
|
||||
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
|
||||
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
|
||||
}
|
||||
|
||||
func TestRegisterAndProviders(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "a"})
|
||||
Register(&stubProvider{name: "b"})
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "a" || got[1].Name() != "b" {
|
||||
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
type priorityProvider struct {
|
||||
stubProvider
|
||||
priority int
|
||||
}
|
||||
|
||||
func (p *priorityProvider) Priority() int { return p.priority }
|
||||
|
||||
func TestRegister_PriorityOrder(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "env"}) // priority 10 (default)
|
||||
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
|
||||
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "x"})
|
||||
snap := Providers()
|
||||
Register(&stubProvider{name: "y"})
|
||||
|
||||
if len(snap) != 1 {
|
||||
t.Fatalf("snapshot should not be affected, got %d", len(snap))
|
||||
}
|
||||
}
|
||||
131
extension/credential/sidecar/provider.go
Normal file
131
extension/credential/sidecar/provider.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a noop credential provider for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
|
||||
// placeholder credentials so the CLI's auth pipeline can proceed normally.
|
||||
// Real tokens are never present in the sandbox; the sidecar transport
|
||||
// interceptor routes requests to the trusted sidecar process instead.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider is the noop credential provider for sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
func (p *Provider) Priority() int { return 0 }
|
||||
|
||||
// ResolveAccount returns a minimal Account when sidecar mode is active.
|
||||
// The account contains AppID and Brand from environment variables, a
|
||||
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
|
||||
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil, nil // not in sidecar mode, skip
|
||||
}
|
||||
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
|
||||
}
|
||||
}
|
||||
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv(envvars.CliProxyKey) == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
}
|
||||
|
||||
// Parse DefaultAs
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off", "":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns a sentinel token whose value encodes the token type.
|
||||
// The transport interceptor reads this sentinel to determine the identity
|
||||
// (user vs bot), strips it, and the sidecar injects the real token.
|
||||
// Returns nil, nil when sidecar mode is not active.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
if os.Getenv(envvars.CliAuthProxy) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sentinel string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
sentinel = sidecar.SentinelUAT
|
||||
case credential.TokenTypeTAT:
|
||||
sentinel = sidecar.SentinelTAT
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &credential.Token{
|
||||
Value: sentinel,
|
||||
Scopes: "", // empty → scope pre-check is skipped
|
||||
Source: "sidecar",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
188
extension/credential/sidecar/provider_test.go
Normal file
188
extension/credential/sidecar/provider_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Setenv(key, value)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAccount_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatal("expected nil account when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, "lark")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected non-nil account")
|
||||
}
|
||||
if acct.AppID != "cli_test123" {
|
||||
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
|
||||
}
|
||||
if acct.Brand != credential.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingProxyKey(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
unsetEnv(t, envvars.CliProxyKey)
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PROXY_KEY is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingAppID(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
unsetEnv(t, envvars.CliAppID)
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when APP_ID is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
want credential.IdentitySupport
|
||||
}{
|
||||
{"bot", credential.SupportsBot},
|
||||
{"user", credential.SupportsUser},
|
||||
{"off", credential.SupportsAll},
|
||||
{"", credential.SupportsAll},
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
for _, tt := range tests {
|
||||
t.Run("strict_"+tt.mode, func(t *testing.T) {
|
||||
if tt.mode == "" {
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
} else {
|
||||
setEnv(t, envvars.CliStrictMode, tt.mode)
|
||||
}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.want {
|
||||
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatal("expected nil token when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_Sentinels(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
// UAT
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("UAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelUAT {
|
||||
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
|
||||
}
|
||||
if tok.Scopes != "" {
|
||||
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
|
||||
}
|
||||
|
||||
// TAT
|
||||
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("TAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelTAT {
|
||||
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
|
||||
}
|
||||
}
|
||||
100
extension/credential/types.go
Normal file
100
extension/credential/types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "context"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandLark Brand = "lark"
|
||||
BrandFeishu Brand = "feishu"
|
||||
)
|
||||
|
||||
// NoAppSecret marks that a credential source does not provide a real app secret.
|
||||
// Token-only sources should return this value instead of inventing placeholder text.
|
||||
const NoAppSecret = ""
|
||||
|
||||
// Identity represents the caller identity type.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
IdentityAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IdentitySupport declares which identities a credential source can provide.
|
||||
type IdentitySupport uint8
|
||||
|
||||
const (
|
||||
SupportsUser IdentitySupport = 1 << iota
|
||||
SupportsBot
|
||||
SupportsAll = SupportsUser | SupportsBot
|
||||
)
|
||||
|
||||
// Has reports whether s includes the given flag.
|
||||
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
|
||||
|
||||
// UserOnly returns true if only user identity is supported.
|
||||
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
type Token struct {
|
||||
Value string
|
||||
Scopes string // space-separated; empty = skip scope pre-check
|
||||
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
|
||||
}
|
||||
|
||||
// TokenType represents the kind of access token.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat"
|
||||
TokenTypeTAT TokenType = "tat"
|
||||
)
|
||||
|
||||
// TokenSpec describes what token is needed.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
return "blocked by " + e.Provider + ": " + e.Reason
|
||||
}
|
||||
|
||||
// Provider is the unified interface for credential resolution.
|
||||
//
|
||||
// Flow control uses Go's native mechanisms:
|
||||
// - Handle: return &Account{...}, nil or return &Token{...}, nil
|
||||
// - Skip: return nil, nil
|
||||
// - Block: return nil, &BlockError{...}
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
|
||||
}
|
||||
39
extension/credential/types_test.go
Normal file
39
extension/credential/types_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySupport_Has(t *testing.T) {
|
||||
if !SupportsAll.Has(SupportsUser) {
|
||||
t.Error("SupportsAll should have SupportsUser")
|
||||
}
|
||||
if !SupportsAll.Has(SupportsBot) {
|
||||
t.Error("SupportsAll should have SupportsBot")
|
||||
}
|
||||
if SupportsUser.Has(SupportsBot) {
|
||||
t.Error("SupportsUser should not have SupportsBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_UserOnly(t *testing.T) {
|
||||
if !SupportsUser.UserOnly() {
|
||||
t.Error("SupportsUser.UserOnly() should be true")
|
||||
}
|
||||
if SupportsAll.UserOnly() {
|
||||
t.Error("SupportsAll.UserOnly() should be false")
|
||||
}
|
||||
if IdentitySupport(0).UserOnly() {
|
||||
t.Error("zero value UserOnly() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_BotOnly(t *testing.T) {
|
||||
if !SupportsBot.BotOnly() {
|
||||
t.Error("SupportsBot.BotOnly() should be true")
|
||||
}
|
||||
if SupportsAll.BotOnly() {
|
||||
t.Error("SupportsAll.BotOnly() should be false")
|
||||
}
|
||||
}
|
||||
40
extension/fileio/errors.go
Normal file
40
extension/fileio/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPathValidation indicates the path failed security validation
|
||||
// (traversal, absolute, control chars, symlink escape, etc.).
|
||||
var ErrPathValidation = errors.New("path validation failed")
|
||||
|
||||
// PathValidationError wraps a path validation error.
|
||||
// errors.Is(err, ErrPathValidation) returns true.
|
||||
// errors.Is(err, <original OS error>) also works via the chain.
|
||||
type PathValidationError struct {
|
||||
Err error // original error
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string { return e.Err.Error() }
|
||||
func (e *PathValidationError) Unwrap() []error {
|
||||
return []error{ErrPathValidation, e.Err}
|
||||
}
|
||||
|
||||
// MkdirError indicates parent directory creation failed.
|
||||
// Use errors.As(err, &fileio.MkdirError{}) to match.
|
||||
type MkdirError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *MkdirError) Error() string { return e.Err.Error() }
|
||||
func (e *MkdirError) Unwrap() error { return e.Err }
|
||||
|
||||
// WriteError indicates file write failed.
|
||||
// Use errors.As(err, &fileio.WriteError{}) to match.
|
||||
type WriteError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *WriteError) Error() string { return e.Err.Error() }
|
||||
func (e *WriteError) Unwrap() error { return e.Err }
|
||||
31
extension/fileio/registry.go
Normal file
31
extension/fileio/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
73
extension/fileio/types.go
Normal file
73
extension/fileio/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
Mode() fs.FileMode
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
51
extension/transport/errors.go
Normal file
51
extension/transport/errors.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrAborted is a sentinel matched by errors.Is on any extension-triggered
|
||||
// round-trip abort. Callers that only need to know whether an error was
|
||||
// caused by an extension interception should use:
|
||||
//
|
||||
// if errors.Is(err, transport.ErrAborted) { ... }
|
||||
var ErrAborted = errors.New("round trip aborted by extension")
|
||||
|
||||
// AbortError is returned by the built-in middleware when an AbortableInterceptor
|
||||
// short-circuits a request via PreRoundTripE. It wraps the extension's original
|
||||
// reason and carries the extension's Provider.Name() for traceability.
|
||||
//
|
||||
// Use errors.As to recover the typed error:
|
||||
//
|
||||
// var aErr *transport.AbortError
|
||||
// if errors.As(err, &aErr) {
|
||||
// log.Printf("blocked by %s: %v", aErr.Extension, aErr.Reason)
|
||||
// }
|
||||
//
|
||||
// errors.Is(err, transport.ErrAborted) also works, and errors.Is against the
|
||||
// inner reason still works via Unwrap.
|
||||
type AbortError struct {
|
||||
// Extension is the name of the Provider whose interceptor aborted the
|
||||
// request (from Provider.Name()). May be empty if the provider did not
|
||||
// supply a name.
|
||||
Extension string
|
||||
// Reason is the original non-nil error returned by PreRoundTripE.
|
||||
Reason error
|
||||
}
|
||||
|
||||
func (e *AbortError) Error() string {
|
||||
if e.Extension != "" {
|
||||
return fmt.Sprintf("extension %q aborted round trip: %v", e.Extension, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("extension aborted round trip: %v", e.Reason)
|
||||
}
|
||||
|
||||
// Unwrap lets errors.Is / errors.As traverse to the underlying Reason.
|
||||
func (e *AbortError) Unwrap() error { return e.Reason }
|
||||
|
||||
// Is enables errors.Is(err, ErrAborted) at any nesting depth.
|
||||
func (e *AbortError) Is(target error) bool { return target == ErrAborted }
|
||||
103
extension/transport/errors_test.go
Normal file
103
extension/transport/errors_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAbortError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AbortError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with extension name",
|
||||
err: &AbortError{Extension: "audit", Reason: errors.New("bad")},
|
||||
want: `extension "audit" aborted round trip: bad`,
|
||||
},
|
||||
{
|
||||
name: "without extension name",
|
||||
err: &AbortError{Reason: errors.New("bad")},
|
||||
want: "extension aborted round trip: bad",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.want {
|
||||
t.Fatalf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_Unwrap(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
e := &AbortError{Reason: reason}
|
||||
if got := e.Unwrap(); got != reason {
|
||||
t.Fatalf("Unwrap() = %v, want %v", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_IsErrAborted(t *testing.T) {
|
||||
e := &AbortError{Reason: errors.New("bad")}
|
||||
if !errors.Is(e, ErrAborted) {
|
||||
t.Fatal("errors.Is(e, ErrAborted) = false, want true")
|
||||
}
|
||||
// Sanity: not matched by unrelated sentinels.
|
||||
if errors.Is(e, errors.New("other")) {
|
||||
t.Fatal("errors.Is matched unrelated sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_UnwrapReachesInnerSentinel(t *testing.T) {
|
||||
// Extensions often return typed/sentinel errors; callers should still be
|
||||
// able to errors.Is against those after the middleware wraps them.
|
||||
innerSentinel := errors.New("policy-deny-42")
|
||||
e := &AbortError{Reason: fmt.Errorf("wrapped: %w", innerSentinel)}
|
||||
if !errors.Is(e, innerSentinel) {
|
||||
t.Fatal("errors.Is(e, innerSentinel) = false, want true (Unwrap chain broken)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_As(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
base := &AbortError{Extension: "audit", Reason: reason}
|
||||
|
||||
// Direct As.
|
||||
var aErr *AbortError
|
||||
if !errors.As(base, &aErr) {
|
||||
t.Fatal("errors.As(base, *AbortError) = false")
|
||||
}
|
||||
if aErr.Extension != "audit" || aErr.Reason != reason {
|
||||
t.Fatalf("aErr = %+v, want {audit, bad}", aErr)
|
||||
}
|
||||
|
||||
// Nested As: even when the *AbortError is wrapped in another error,
|
||||
// errors.As must still find it via Unwrap chain.
|
||||
wrapped := fmt.Errorf("outer: %w", base)
|
||||
var aErr2 *AbortError
|
||||
if !errors.As(wrapped, &aErr2) {
|
||||
t.Fatal("errors.As(wrapped, *AbortError) = false")
|
||||
}
|
||||
if aErr2 != base {
|
||||
t.Fatalf("aErr2 = %p, want %p", aErr2, base)
|
||||
}
|
||||
|
||||
// errors.Is still matches the sentinel through the outer wrapper.
|
||||
if !errors.Is(wrapped, ErrAborted) {
|
||||
t.Fatal("errors.Is(wrapped, ErrAborted) = false via nested wrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrAborted_IsItselfSentinel(t *testing.T) {
|
||||
// Guard against accidental re-assignment of ErrAborted: a bare ErrAborted
|
||||
// value should still satisfy errors.Is(err, ErrAborted) for symmetry.
|
||||
if !errors.Is(ErrAborted, ErrAborted) {
|
||||
t.Fatal("errors.Is(ErrAborted, ErrAborted) = false")
|
||||
}
|
||||
}
|
||||
28
extension/transport/registry.go
Normal file
28
extension/transport/registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a transport Provider.
|
||||
// Later registrations override earlier ones.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
77
extension/transport/registry_test.go
Normal file
77
extension/transport/registry_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubInterceptor struct{}
|
||||
|
||||
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
|
||||
|
||||
func TestGetProvider_NilByDefault(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
if got := GetProvider(); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndGet(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "a"}
|
||||
Register(p)
|
||||
|
||||
got := GetProvider()
|
||||
if got != p {
|
||||
t.Fatalf("expected registered provider, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastRegistrationWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
a := &stubProvider{name: "a"}
|
||||
b := &stubProvider{name: "b"}
|
||||
Register(a)
|
||||
Register(b)
|
||||
|
||||
got := GetProvider()
|
||||
if got != b {
|
||||
t.Fatalf("expected provider b, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "test"}
|
||||
Register(p)
|
||||
|
||||
ic := GetProvider().ResolveInterceptor(context.Background())
|
||||
if ic == nil {
|
||||
t.Fatal("expected non-nil Interceptor")
|
||||
}
|
||||
}
|
||||
178
extension/transport/sidecar/interceptor.go
Normal file
178
extension/transport/sidecar/interceptor.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider implements transport.Provider for the sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
|
||||
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
||||
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
||||
// in the latter case a warning is emitted to stderr and requests fall back to
|
||||
// the non-sidecar transport path (where the credential layer will typically
|
||||
// block them for lack of a valid account).
|
||||
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
||||
return nil
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
||||
// URLs) are passed through unmodified.
|
||||
//
|
||||
// Supports two auth patterns:
|
||||
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
||||
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
||||
identity, authHeader := detectSentinel(req)
|
||||
if identity == "" {
|
||||
return nil // not a sidecar-managed request, pass through
|
||||
}
|
||||
|
||||
// 1. Buffer the body first, before mutating any request state. A partial
|
||||
// read would sign a truncated body and cause a misleading HMAC mismatch
|
||||
// on the sidecar side; bail out early and let the request fall through
|
||||
// unmodified so the credential layer can surface an actionable error.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if req.GetBody != nil {
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save original target (scheme://host)
|
||||
originalScheme := "https"
|
||||
if req.URL.Scheme != "" {
|
||||
originalScheme = req.URL.Scheme
|
||||
}
|
||||
originalHost := req.URL.Host
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
||||
|
||||
// 3. Set identity and tell sidecar which header to inject real token into
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
|
||||
// 4. Strip placeholder auth header(s)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
bodySHA := sidecar.BodySHA256(bodyBytes)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
|
||||
pathAndQuery := req.URL.RequestURI()
|
||||
ts := sidecar.Timestamp()
|
||||
// Cover identity and authHeader in the signature so an on-path attacker
|
||||
// within the replay window cannot flip the injected token's identity or
|
||||
// redirect the token into a different header.
|
||||
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: req.Method,
|
||||
Host: originalHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
}
|
||||
|
||||
// detectSentinel checks both standard Authorization and MCP auth headers for
|
||||
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
||||
// that carried the sentinel.
|
||||
//
|
||||
// Returns ("", "") when the request carries no sentinel token — typically
|
||||
// requests that require no auth (e.g. pre-signed download URLs where the
|
||||
// token is embedded in the URL query parameters).
|
||||
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
||||
// Check standard Authorization: Bearer <sentinel>
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
switch token {
|
||||
case sidecar.SentinelUAT:
|
||||
return sidecar.IdentityUser, "Authorization"
|
||||
case sidecar.SentinelTAT:
|
||||
return sidecar.IdentityBot, "Authorization"
|
||||
}
|
||||
}
|
||||
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
||||
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
||||
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
||||
return
|
||||
}
|
||||
transport.Register(&Provider{})
|
||||
}
|
||||
265
extension/transport/sidecar/interceptor_test.go
Normal file
265
extension/transport/sidecar/interceptor_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
closed bool
|
||||
readCall bool
|
||||
}
|
||||
|
||||
func (b *failingBody) Read(p []byte) (int, error) {
|
||||
b.readCall = true
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b *failingBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
body := []byte(`{"msg":"hello"}`)
|
||||
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
req.Header.Set("X-Cli-Source", "lark-cli")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook")
|
||||
}
|
||||
|
||||
// URL should be rewritten to sidecar
|
||||
if req.URL.Scheme != "http" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
||||
}
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
||||
}
|
||||
|
||||
// Original target should be preserved
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
|
||||
// Identity should be user (from SentinelUAT)
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
|
||||
// Authorization should be stripped
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization header should be stripped, got %q", auth)
|
||||
}
|
||||
|
||||
// HMAC headers should be set
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
||||
t.Error("timestamp header should be set")
|
||||
}
|
||||
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
||||
t.Error("body SHA256 header should be set")
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
||||
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
||||
}
|
||||
|
||||
// Non-proxy headers should be preserved
|
||||
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
||||
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
||||
}
|
||||
|
||||
// Body should still be readable
|
||||
readBody, _ := io.ReadAll(req.Body)
|
||||
if !bytes.Equal(readBody, body) {
|
||||
t.Errorf("body should be preserved after PreRoundTrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer some-real-token")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should NOT be rewritten — no sentinel token
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook for pass-through")
|
||||
}
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
||||
t.Error("proxy target header should not be set for pass-through")
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
||||
t.Error("Authorization should be preserved for pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://cdn.feishu.cn/download/file"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// No Authorization header at all — should pass through
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_UAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should be intercepted and rewritten
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
||||
}
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
||||
}
|
||||
// MCP sentinel should be stripped
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_TAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
||||
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
||||
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
||||
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
||||
// the sidecar side) and releases the original body.
|
||||
func TestInterceptor_BodyReadError(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
body := &failingBody{err: errors.New("disk gremlin")}
|
||||
|
||||
req, _ := http.NewRequest("POST", origURL, body)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook on body read failure")
|
||||
}
|
||||
|
||||
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
||||
if !body.readCall {
|
||||
t.Error("expected ReadAll to have attempted reading from the body")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("expected original body to be Close()'d after read failure")
|
||||
}
|
||||
|
||||
// URL must NOT be rewritten — request should fall through to the next
|
||||
// layer (credential) which can surface a meaningful error.
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
||||
}
|
||||
|
||||
// No proxy/HMAC headers should leak onto the request.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion,
|
||||
sidecar.HeaderProxyTarget,
|
||||
sidecar.HeaderProxySignature,
|
||||
sidecar.HeaderProxyTimestamp,
|
||||
sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
||||
expectedEmpty := sidecar.BodySHA256(nil)
|
||||
if sha != expectedEmpty {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
57
extension/transport/types.go
Normal file
57
extension/transport/types.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider creates Interceptor instances.
|
||||
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
//
|
||||
// PreRoundTrip is called before the built-in chain. Use it to add custom
|
||||
// headers, rewrite the host, or start trace spans. Built-in decorators run
|
||||
// after this and will override any same-named security headers set here.
|
||||
// The extension must not replace req.Context() — the middleware restores
|
||||
// the original context after PreRoundTrip returns.
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
//
|
||||
// Body note: the middleware Clones the caller's request before invoking the
|
||||
// interceptor, which copies headers/URL/etc. but shares the underlying
|
||||
// io.ReadCloser. Extensions that read req.Body are responsible for restoring
|
||||
// a replayable body (e.g. via req.GetBody) before returning, otherwise the
|
||||
// built-in chain will see an exhausted stream.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
|
||||
// AbortableInterceptor is an optional extension of Interceptor that lets an
|
||||
// extension reject a request before the built-in chain runs. Extensions that
|
||||
// implement this interface are detected by the built-in middleware via a
|
||||
// type assertion; both methods must be present, but when an extension
|
||||
// implements PreRoundTripE the middleware will NOT call PreRoundTrip.
|
||||
//
|
||||
// Returning a non-nil error from PreRoundTripE aborts the request: the
|
||||
// built-in chain is not executed and the middleware returns an *AbortError
|
||||
// wrapping the reason. The returned post function (if non-nil) is still
|
||||
// invoked with (nil, reason) so that extensions can unwind any state they
|
||||
// created in the pre hook (spans, metrics, audit records).
|
||||
//
|
||||
// Extensions that only care about the abortable variant can provide a no-op
|
||||
// PreRoundTrip method alongside PreRoundTripE to satisfy Interceptor.
|
||||
type AbortableInterceptor interface {
|
||||
Interceptor
|
||||
PreRoundTripE(req *http.Request) (post func(resp *http.Response, err error), err error)
|
||||
}
|
||||
11
go.mod
11
go.mod
@@ -7,10 +7,14 @@ require (
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.33.0
|
||||
@@ -30,6 +34,7 @@ require (
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
@@ -37,6 +42,7 @@ require (
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -46,9 +52,12 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user