mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -6,3 +6,6 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
Normal file
116
.github/workflows/arch-audit.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Architecture Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Monday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Dead code detection
|
||||
run: |
|
||||
echo "## Dead Code" >> report.md
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
|
||||
count=$(wc -l < deadcode.txt | tr -d ' ')
|
||||
echo "Found **$count** unreachable functions" >> report.md
|
||||
echo '```' >> report.md
|
||||
cat deadcode.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Package complexity
|
||||
run: |
|
||||
echo "## Package Complexity" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "| Package | Files | Lines | Deps |" >> report.md
|
||||
echo "|---------|-------|-------|------|" >> report.md
|
||||
found=0
|
||||
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
|
||||
dir=$(go list -f '{{.Dir}}' "$pkg")
|
||||
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
|
||||
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
|
||||
deps=$(go list -f '{{len .Imports}}' "$pkg")
|
||||
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
|
||||
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "| _(none)_ | | | |" >> report.md
|
||||
fi
|
||||
|
||||
- name: Dependency freshness
|
||||
run: |
|
||||
echo "## Outdated Dependencies" >> report.md
|
||||
echo '```' >> report.md
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Shortcut domains without E2E tests:" >> report.md
|
||||
echo "" >> report.md
|
||||
found=0
|
||||
for domain in $(ls -d shortcuts/*/); do
|
||||
name=$(basename "$domain")
|
||||
if [ "$name" = "common" ]; then continue; fi
|
||||
if [ ! -d "tests/cli_e2e/$name" ]; then
|
||||
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "All shortcut domains have E2E test directories." >> report.md
|
||||
fi
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
echo "## Coverage" >> report.md
|
||||
packages=$(go list ./... | grep -v 'tests/cli_e2e')
|
||||
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
|
||||
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
|
||||
echo "Current total coverage: **${total:-n/a}**" >> report.md
|
||||
|
||||
- name: Publish report
|
||||
run: |
|
||||
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cat report.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: arch-audit-${{ github.run_number }}
|
||||
path: report.md
|
||||
retention-days: 90
|
||||
334
.github/workflows/ci.yml
vendored
Normal file
334
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
fast-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "$unformatted"
|
||||
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
|
||||
exit 1
|
||||
fi
|
||||
- name: Check go.mod tidiness
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
|
||||
exit 1
|
||||
fi
|
||||
- name: Coverage summary
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [ ! -f coverage.txt ]; then
|
||||
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Dead code check (incremental)
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
git worktree remove -f /tmp/dc-base 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
git worktree remove -f /tmp/dc-base
|
||||
|
||||
# Only new dead code blocks the PR
|
||||
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
|
||||
if [ -s /tmp/dc-new.txt ]; then
|
||||
echo "::group::New dead code"
|
||||
cat /tmp/dc-new.txt
|
||||
echo "::endgroup::"
|
||||
echo "::error::New dead code detected — remove unreachable functions before merging"
|
||||
exit 1
|
||||
fi
|
||||
echo "No new dead code introduced"
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Gitleaks
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
- name: govulncheck
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
license-header:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
run: |
|
||||
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo ""
|
||||
echo "::error::One or more CI jobs failed — see table above"
|
||||
exit 1
|
||||
fi
|
||||
135
.github/workflows/cli-e2e.yml
vendored
135
.github/workflows/cli-e2e.yml
vendored
@@ -1,135 +0,0 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
58
.github/workflows/coverage.yml
vendored
58
.github/workflows/coverage.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
28
.github/workflows/gitleaks.yml
vendored
28
.github/workflows/gitleaks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
26
.github/workflows/license-header.yml
vendored
26
.github/workflows/license-header.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
60
.github/workflows/lint.yml
vendored
60
.github/workflows/lint.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
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 ./...
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,3 +35,6 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
@@ -70,6 +70,14 @@ linters:
|
||||
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 ──
|
||||
@@ -100,6 +108,16 @@ linters:
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
# ── output: shortcuts must use ctx.Out() ──
|
||||
- pattern: fmt\.Print(f|ln)?\b
|
||||
msg: >-
|
||||
use ctx.Out() or ctx.OutFormat() for structured JSON output.
|
||||
fmt.Print* bypasses the output envelope and breaks --jq/--format.
|
||||
# ── logging: shortcuts must return errors, not log.Fatal ──
|
||||
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
|
||||
msg: >-
|
||||
use structured error return, not log.Fatal/Panic.
|
||||
Shortcuts must return errors to the framework for proper exit code handling.
|
||||
# ── filepath: functions that access the filesystem ──
|
||||
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
|
||||
msg: >-
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -18,9 +18,11 @@ 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`
|
||||
2. `go vet ./...`
|
||||
3. `gofmt -l .` — must produce no output
|
||||
4. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
@@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
### E2E Testing
|
||||
|
||||
**Dry-run E2E (required for every shortcut change)**
|
||||
- Validates request structure without calling real APIs
|
||||
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
|
||||
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
|
||||
- No secrets needed — runs on fork PRs
|
||||
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
|
||||
|
||||
**Live E2E (required for new flows or behavior changes)**
|
||||
- Validates real API round-trips
|
||||
- Place in `tests/cli_e2e/<domain>/`
|
||||
- Must be self-contained: create -> use -> cleanup
|
||||
- Needs bot credentials (CI secrets, skipped on fork PRs)
|
||||
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
|
||||
|
||||
| Change | Dry-run E2E | Live E2E |
|
||||
|--------|:-----------:|:--------:|
|
||||
| New shortcut | Required | Required |
|
||||
| Modify shortcut flags/params | Required | If behavior changes |
|
||||
| Shortcut bug fix | Required | If regression risk |
|
||||
| Internal refactor (no shortcut impact) | Not needed | Not needed |
|
||||
|
||||
145
CHANGELOG.md
145
CHANGELOG.md
@@ -2,6 +2,144 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -303,6 +441,13 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
11
README.md
11
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, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 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** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 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
|
||||
@@ -30,13 +30,15 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 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 |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| 🖼️ 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
|
||||
|
||||
@@ -149,6 +151,7 @@ lark-cli auth status
|
||||
| `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 |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 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 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -36,7 +36,9 @@
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -150,6 +152,7 @@ lark-cli auth status
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
@@ -57,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
|
||||
|
||||
@@ -79,7 +83,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
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)")
|
||||
@@ -96,9 +100,6 @@ 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) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -180,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,
|
||||
|
||||
@@ -184,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,
|
||||
|
||||
@@ -24,11 +24,11 @@ type loginMsg struct {
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
@@ -58,13 +58,13 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
@@ -93,13 +93,13 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
@@ -128,7 +128,7 @@ func emptyIfNil(s []string) []string {
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
@@ -136,7 +136,6 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
@@ -190,11 +189,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,11 +376,10 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -392,15 +391,18 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -469,13 +471,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -490,10 +492,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -508,9 +510,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -619,10 +621,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -634,6 +635,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +750,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,16 +778,18 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Not granted scopes:") {
|
||||
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -3,15 +3,38 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/pflag"
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree.
|
||||
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
|
||||
// is a build-time policy — when true, --profile stays parseable but is marked
|
||||
// hidden from help and shell completion.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
Profile string
|
||||
HideProfile bool
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
// RegisterGlobalFlags registers the root-level persistent flags on fs and
|
||||
// applies any visibility policy encoded in opts. Pure function: no disk,
|
||||
// network, or environment reads — the caller decides HideProfile.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
if opts.HideProfile {
|
||||
_ = fs.MarkHidden("profile")
|
||||
}
|
||||
}
|
||||
|
||||
// isSingleAppMode reports whether the on-disk config has at most one app.
|
||||
// Missing configs are treated as single-app since --profile is meaningless
|
||||
// until at least two profiles exist. Intended for the Execute entry point —
|
||||
// buildInternal must not call this directly to stay state-free.
|
||||
func isSingleAppMode() bool {
|
||||
raw, err := core.LoadMultiAppConfig()
|
||||
if err != nil || raw == nil {
|
||||
return true
|
||||
}
|
||||
return len(raw.Apps) <= 1
|
||||
}
|
||||
|
||||
110
cmd/global_flags_test.go
Normal file
110
cmd/global_flags_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible when HideProfile is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{HideProfile: true}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile is true")
|
||||
}
|
||||
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
|
||||
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
|
||||
}
|
||||
if opts.Profile != "x" {
|
||||
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_NoConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true when no config exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true for single-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered by default")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible by default")
|
||||
}
|
||||
}
|
||||
|
||||
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
18
cmd/init.go
Normal file
18
cmd/init.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
// SetDefaultFS replaces the global filesystem implementation used by internal
|
||||
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
|
||||
// the default OS filesystem is restored.
|
||||
//
|
||||
// Call this before Build or Execute to take effect.
|
||||
func SetDefaultFS(fs vfs.FS) {
|
||||
if fs == nil {
|
||||
fs = vfs.OsFs{}
|
||||
}
|
||||
vfs.DefaultFS = fs
|
||||
}
|
||||
58
cmd/root.go
58
cmd/root.go
@@ -14,15 +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/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -30,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"
|
||||
)
|
||||
|
||||
@@ -95,38 +85,11 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -277,10 +240,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 {
|
||||
|
||||
@@ -135,10 +135,12 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
f := cmdutil.NewDefault(
|
||||
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
||||
cmdutil.InvocationContext{Profile: profile},
|
||||
)
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -359,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)
|
||||
}
|
||||
@@ -367,7 +375,7 @@ 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) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -379,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)
|
||||
@@ -469,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
|
||||
}
|
||||
@@ -492,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")
|
||||
@@ -500,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
|
||||
@@ -525,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
|
||||
}
|
||||
|
||||
@@ -182,3 +182,49 @@ func TestHasFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import (
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
RegisterServiceCommandsWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
@@ -38,11 +42,15 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerService(parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
@@ -70,11 +78,11 @@ func registerService(parent *cobra.Command, spec map[string]interface{}, resourc
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResource(svc, spec, resName, resMap, f)
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
@@ -87,7 +95,7 @@ func registerResource(parent *cobra.Command, spec map[string]interface{}, name s
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethod(res, spec, methodMap, methodName, name, f)
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,16 +109,16 @@ type ServiceMethodOptions struct {
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
@@ -120,12 +128,16 @@ func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
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))
|
||||
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")
|
||||
@@ -159,7 +171,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
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)")
|
||||
@@ -177,10 +189,6 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = 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) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -121,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) {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -11,12 +14,28 @@ var (
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// 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).
|
||||
|
||||
@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,31 @@ type Provider interface {
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
errStr := getStr(data, "error")
|
||||
|
||||
if errStr == "" && getStr(data, "access_token") != "" {
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
|
||||
refreshToken := getStr(data, "refresh_token")
|
||||
tokenExpiresIn := getInt(data, "expires_in", 7200)
|
||||
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
|
||||
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -34,27 +32,24 @@ import (
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
IOStreams: streams,
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: f.Keychain,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
@@ -93,11 +88,11 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
@@ -122,7 +117,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -134,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.NewBaseTransport()
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
@@ -142,7 +137,7 @@ func buildSDKTransport() http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain keychain.KeychainAccess
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
|
||||
@@ -6,14 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
@@ -63,7 +59,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
@@ -103,7 +99,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
@@ -120,22 +116,6 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
@@ -144,7 +124,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
@@ -164,7 +144,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -189,7 +169,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -217,7 +197,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
@@ -232,170 +212,3 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (s *stubTransportProvider) Name() string { return "stub" }
|
||||
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if s.interceptor != nil {
|
||||
return s.interceptor
|
||||
}
|
||||
return &stubTransportImpl{}
|
||||
}
|
||||
|
||||
type stubTransportImpl struct{}
|
||||
|
||||
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
|
||||
// whether PostRoundTrip was called, to verify execution order.
|
||||
type headerCapturingInterceptor struct {
|
||||
preCalled bool
|
||||
postCalled bool
|
||||
}
|
||||
|
||||
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
h.preCalled = true
|
||||
// Set a custom header that should survive (no built-in override)
|
||||
req.Header.Set("X-Custom-Trace", "ext-trace-123")
|
||||
// Try to override a security header — should be overwritten by SecurityHeaderTransport
|
||||
req.Header.Set(HeaderSource, "ext-tampered")
|
||||
return func(resp *http.Response, err error) {
|
||||
h.postCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var receivedHeaders http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ic := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&stubTransportProvider{interceptor: ic})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Use HTTP transport chain (has SecurityHeaderTransport)
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// PreRoundTrip was called
|
||||
if !ic.preCalled {
|
||||
t.Fatal("PreRoundTrip was not called")
|
||||
}
|
||||
// PostRoundTrip (closure) was called
|
||||
if !ic.postCalled {
|
||||
t.Fatal("PostRoundTrip closure was not called")
|
||||
}
|
||||
// Custom header set by extension survives (no built-in override)
|
||||
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
|
||||
}
|
||||
// Security header overridden by extension is restored by SecurityHeaderTransport
|
||||
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
type ctxKeyType string
|
||||
const testKey ctxKeyType = "original"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var ctxValue any
|
||||
|
||||
// Use a custom transport that captures the context value seen by the built-in chain
|
||||
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
ctxValue = req.Context().Value(testKey)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})
|
||||
|
||||
// Interceptor that tries to tamper with context
|
||||
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
|
||||
// Try to replace context with a new one
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Built-in chain should see original context, not tampered
|
||||
if ctxValue != "original" {
|
||||
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
|
||||
}
|
||||
}
|
||||
|
||||
// interceptorFunc adapts a function to exttransport.Interceptor.
|
||||
type interceptorFunc func(*http.Request) func(*http.Response, error)
|
||||
|
||||
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
fd.AddField(k, formatFormFieldValue(v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
||||
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
||||
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
||||
// backends reject when parsing the field as an integer. Use decimal notation
|
||||
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
||||
// All other types fall through to %v.
|
||||
func formatFormFieldValue(v any) string {
|
||||
if n, ok := v.(float64); ok {
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
|
||||
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
|
||||
// float64 delegates to %g which switches to scientific notation at ~1e6
|
||||
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
|
||||
// integer reject that, surfacing as a generic "params error".
|
||||
func TestFormatFormFieldValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
|
||||
{"float64 below scientific threshold", float64(358934), "358934"},
|
||||
{"float64 zero", float64(0), "0"},
|
||||
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
|
||||
{"float64 negative", float64(-42), "-42"},
|
||||
{"float64 fractional preserved", float64(3.14), "3.14"},
|
||||
{"string pass-through", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"int via %v", 42, "42"},
|
||||
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatFormFieldValue(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
68
internal/cmdutil/identity_flag.go
Normal file
68
internal/cmdutil/identity_flag.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
|
||||
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
|
||||
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
|
||||
defaultValue: "auto",
|
||||
usage: "identity type: user | bot | auto (default)",
|
||||
completionValues: []string{"user", "bot"},
|
||||
})
|
||||
}
|
||||
|
||||
// AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts.
|
||||
func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) {
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
|
||||
defaultValue: authTypes[0],
|
||||
usage: "identity type: " + strings.Join(authTypes, " | "),
|
||||
completionValues: authTypes,
|
||||
})
|
||||
}
|
||||
|
||||
type identityFlagConfig struct {
|
||||
defaultValue string
|
||||
usage string
|
||||
completionValues []string
|
||||
}
|
||||
|
||||
// addIdentityFlag centralizes --as registration and strict-mode UX.
|
||||
// When strict mode is active, the flag is still accepted for compatibility
|
||||
// but hidden from help/completion and locked to the forced identity by default.
|
||||
func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) {
|
||||
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
|
||||
// Keep registering --as in strict mode even though it is hidden.
|
||||
// This preserves parser compatibility for existing invocations that still pass
|
||||
// --as, and keeps downstream GetString("as") / ResolveAs paths stable.
|
||||
// The usage text below is effectively placeholder text because the flag is hidden.
|
||||
registerIdentityFlag(cmd, target, string(forced),
|
||||
fmt.Sprintf("identity locked to %s by strict mode (admin-managed)", forced))
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
return
|
||||
}
|
||||
|
||||
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
func registerIdentityFlag(cmd *cobra.Command, target *string, defaultValue, usage string) {
|
||||
if target != nil {
|
||||
cmd.Flags().StringVar(target, "as", defaultValue, usage)
|
||||
return
|
||||
}
|
||||
cmd.Flags().String("as", defaultValue, usage)
|
||||
}
|
||||
68
internal/cmdutil/identity_flag_test.go
Normal file
68
internal/cmdutil/identity_flag_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddAPIIdentityFlag(context.Background(), cmd, 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 visible outside strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "auto" {
|
||||
t.Fatalf("default value = %q, want %q", got, "auto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s", SupportedIdentities: 2,
|
||||
})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddAPIIdentityFlag(context.Background(), cmd, 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 TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"})
|
||||
|
||||
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 visible outside strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "io"
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IOStreams provides the standard input/output/error streams.
|
||||
// Commands should use these instead of os.Stdin/Stdout/Stderr
|
||||
@@ -14,3 +19,45 @@ type IOStreams struct {
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal is derived from in's underlying *os.File, if any; non-file
|
||||
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
//
|
||||
//nolint:forbidigo // entry point for real stdio
|
||||
func SystemIO() *IOStreams {
|
||||
return NewIOStreams(os.Stdin, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
// normalizeStreams returns a fresh IOStreams with any nil field filled from
|
||||
// SystemIO(). Callers constructing a partial struct like &IOStreams{Out: buf}
|
||||
// get a usable result without nil writers leaking into RoundTripper warnings,
|
||||
// Cobra I/O, or credential-provider error paths.
|
||||
func normalizeStreams(s *IOStreams) *IOStreams {
|
||||
if s == nil {
|
||||
return SystemIO()
|
||||
}
|
||||
out := *s
|
||||
if out.In == nil || out.Out == nil || out.ErrOut == nil {
|
||||
sys := SystemIO()
|
||||
if out.In == nil {
|
||||
out.In = sys.In
|
||||
}
|
||||
if out.Out == nil {
|
||||
out.Out = sys.Out
|
||||
}
|
||||
if out.ErrOut == nil {
|
||||
out.ErrOut = sys.ErrOut
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestRetryTransport_NoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base, MaxRetries: 0}
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransport_RetryOn500(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("expected 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base} // default MaxRetries=0
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 500 {
|
||||
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call with default config, got %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -104,20 +104,47 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes and cannot be skipped or overridden.
|
||||
// The original request context is restored after PreRoundTrip to prevent
|
||||
// The built-in chain always executes unless the extension is an
|
||||
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
|
||||
// error; it cannot otherwise be skipped or overridden.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
// Cloning the request isolates header/URL/etc. mutations from the caller's
|
||||
// request object; req.Body is intentionally shared — extensions that consume
|
||||
// it are responsible for rewinding (see Interceptor doc).
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
|
||||
}
|
||||
|
||||
// RoundTrip calls PreRoundTrip, restores the original context, executes
|
||||
// the built-in chain, then calls the post hook if non-nil.
|
||||
// RoundTrip invokes the interceptor pre hook, restores the original context,
|
||||
// executes the built-in chain (unless aborted), then calls the post hook if
|
||||
// non-nil. When the extension implements AbortableInterceptor and returns a
|
||||
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
|
||||
// *exttransport.AbortError is returned; the post hook is still invoked with
|
||||
// (nil, reason) so extensions can unwind resources.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx) // isolate caller's request before extension mutations
|
||||
post := m.Ext.PreRoundTrip(req)
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortEr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortEr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortEr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortEr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
@@ -137,5 +164,5 @@ func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
|
||||
}
|
||||
|
||||
408
internal/cmdutil/transport_test.go
Normal file
408
internal/cmdutil/transport_test.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RetryTransport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRetryTransport_NoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base, MaxRetries: 0}
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransport_RetryOn500(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("expected 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
|
||||
})
|
||||
rt := &RetryTransport{Base: base} // default MaxRetries=0
|
||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 500 {
|
||||
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call with default config, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extensionMiddleware — legacy Interceptor path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (s *stubTransportProvider) Name() string { return "stub" }
|
||||
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if s.interceptor != nil {
|
||||
return s.interceptor
|
||||
}
|
||||
return &stubTransportImpl{}
|
||||
}
|
||||
|
||||
type stubTransportImpl struct{}
|
||||
|
||||
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
|
||||
// whether PostRoundTrip was called, to verify execution order.
|
||||
type headerCapturingInterceptor struct {
|
||||
preCalled bool
|
||||
postCalled bool
|
||||
}
|
||||
|
||||
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
h.preCalled = true
|
||||
// Set a custom header that should survive (no built-in override)
|
||||
req.Header.Set("X-Custom-Trace", "ext-trace-123")
|
||||
// Try to override a security header — should be overwritten by SecurityHeaderTransport
|
||||
req.Header.Set(HeaderSource, "ext-tampered")
|
||||
return func(resp *http.Response, err error) {
|
||||
h.postCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var receivedHeaders http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ic := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&stubTransportProvider{interceptor: ic})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Use HTTP transport chain (has SecurityHeaderTransport)
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// PreRoundTrip was called
|
||||
if !ic.preCalled {
|
||||
t.Fatal("PreRoundTrip was not called")
|
||||
}
|
||||
// PostRoundTrip (closure) was called
|
||||
if !ic.postCalled {
|
||||
t.Fatal("PostRoundTrip closure was not called")
|
||||
}
|
||||
// Custom header set by extension survives (no built-in override)
|
||||
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
|
||||
}
|
||||
// Security header overridden by extension is restored by SecurityHeaderTransport
|
||||
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
// interceptorFunc adapts a function to exttransport.Interceptor.
|
||||
type interceptorFunc func(*http.Request) func(*http.Response, error)
|
||||
|
||||
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
|
||||
|
||||
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
type ctxKeyType string
|
||||
const testKey ctxKeyType = "original"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var ctxValue any
|
||||
|
||||
// Use a custom transport that captures the context value seen by the built-in chain
|
||||
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
ctxValue = req.Context().Value(testKey)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})
|
||||
|
||||
// Interceptor that tries to tamper with context
|
||||
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
|
||||
// Try to replace context with a new one
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Built-in chain should see original context, not tampered
|
||||
if ctxValue != "original" {
|
||||
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extensionMiddleware — PreRoundTripE abort path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// abortingInterceptor implements exttransport.AbortableInterceptor and
|
||||
// records invocation of the pre and post hooks. These middleware tests only
|
||||
// assert middleware-level integration; pure *AbortError behavior
|
||||
// (Error/Unwrap/Is/As) is covered in extension/transport/errors_test.go.
|
||||
type abortingInterceptor struct {
|
||||
reason error // if non-nil, PreRoundTripE returns this to abort
|
||||
nilPost bool // if true, PreRoundTripE returns a nil post func
|
||||
preECalled bool
|
||||
postCalled bool
|
||||
postResp *http.Response
|
||||
postErr error
|
||||
}
|
||||
|
||||
// PreRoundTrip is a no-op that satisfies the legacy Interceptor method; the
|
||||
// middleware never calls it when PreRoundTripE is present.
|
||||
func (*abortingInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *abortingInterceptor) PreRoundTripE(req *http.Request) (func(*http.Response, error), error) {
|
||||
a.preECalled = true
|
||||
if a.nilPost {
|
||||
return nil, a.reason
|
||||
}
|
||||
return func(resp *http.Response, err error) {
|
||||
a.postCalled = true
|
||||
a.postResp = resp
|
||||
a.postErr = err
|
||||
}, a.reason
|
||||
}
|
||||
|
||||
func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
innerErr := errors.New("denied by policy")
|
||||
|
||||
t.Run("skips base and wires AbortError fields", func(t *testing.T) {
|
||||
ic := &abortingInterceptor{reason: innerErr}
|
||||
baseCalls := 0
|
||||
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
if resp != nil {
|
||||
t.Fatalf("resp = %v, want nil on abort", resp)
|
||||
}
|
||||
if baseCalls != 0 {
|
||||
t.Fatalf("base RoundTrip called %d times on abort, want 0", baseCalls)
|
||||
}
|
||||
if !ic.preECalled {
|
||||
t.Fatal("PreRoundTripE was not called")
|
||||
}
|
||||
|
||||
var aErr *exttransport.AbortError
|
||||
if !errors.As(err, &aErr) {
|
||||
t.Fatalf("errors.As(*AbortError) = false, err = %v (%T)", err, err)
|
||||
}
|
||||
if aErr.Extension != "stub" || aErr.Reason != innerErr {
|
||||
t.Fatalf("AbortError = %+v, want {Extension:stub Reason:%v}", aErr, innerErr)
|
||||
}
|
||||
|
||||
// Post must see the original inner err, not the *AbortError wrapper.
|
||||
if !ic.postCalled {
|
||||
t.Fatal("post hook was not called on abort")
|
||||
}
|
||||
if ic.postResp != nil {
|
||||
t.Fatalf("post resp = %v, want nil", ic.postResp)
|
||||
}
|
||||
if ic.postErr != innerErr {
|
||||
t.Fatalf("post err = %v, want original inner err %v", ic.postErr, innerErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil post still returns AbortError", func(t *testing.T) {
|
||||
ic := &abortingInterceptor{reason: innerErr, nilPost: true}
|
||||
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("base must not be called on abort")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
var aErr *exttransport.AbortError
|
||||
if !errors.As(err, &aErr) {
|
||||
t.Fatalf("errors.As(*AbortError) = false, err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
ic := &abortingInterceptor{} // reason == nil → no abort
|
||||
baseCalls := 0
|
||||
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("happy path returned err: %v", err)
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("resp = %v, want 200", resp)
|
||||
}
|
||||
if baseCalls != 1 {
|
||||
t.Fatalf("base RoundTrip called %d times, want 1", baseCalls)
|
||||
}
|
||||
if !ic.preECalled {
|
||||
t.Fatal("PreRoundTripE was not called")
|
||||
}
|
||||
if !ic.postCalled || ic.postErr != nil {
|
||||
t.Fatalf("post hook not called or err != nil: called=%v err=%v", ic.postCalled, ic.postErr)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,14 @@ import (
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain keychain.KeychainAccess
|
||||
keychain func() keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
if kc == nil {
|
||||
kc = keychain.Default
|
||||
}
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type noopKC struct{}
|
||||
@@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
|
||||
@@ -11,4 +11,8 @@ const (
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
)
|
||||
|
||||
@@ -33,6 +33,14 @@ const (
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -60,6 +68,20 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
71
internal/output/lark_errors_test.go
Normal file
71
internal/output/lark_errors_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
|
||||
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantExitCode int
|
||||
wantType string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: LarkErrDriveResourceContention,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
},
|
||||
{
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: LarkErrDriveCrossBrand,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
{
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
|
||||
if gotExitCode != tt.wantExitCode {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
|
||||
}
|
||||
if gotHint == "" {
|
||||
t.Fatal("expected non-empty hint")
|
||||
}
|
||||
if !strings.Contains(gotHint, tt.wantHint) {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +146,20 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
// RunSkillsUpdate installs skills, trying the .well-known source first and
|
||||
// falling back to the GitHub repo on failure or timeout.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
}
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -159,7 +168,7 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -61,7 +61,7 @@ func httpClient() *http.Client {
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: util.NewBaseTransport(),
|
||||
Transport: util.SharedTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,31 +72,47 @@ func WarnIfProxied(w io.Writer) {
|
||||
})
|
||||
}
|
||||
|
||||
// NewBaseTransport creates an *http.Transport cloned from http.DefaultTransport.
|
||||
// If LARK_CLI_NO_PROXY is set, proxy support is disabled.
|
||||
// Each call returns a new instance; use FallbackTransport for a shared singleton.
|
||||
func NewBaseTransport() *http.Transport {
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
|
||||
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
t := def.Clone()
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
t.Proxy = nil
|
||||
}
|
||||
t.Proxy = nil
|
||||
return t
|
||||
}
|
||||
|
||||
// fallbackTransport is a lazily-initialized singleton used by transport
|
||||
// decorators when their Base field is nil, preserving connection pooling.
|
||||
var fallbackTransport = sync.OnceValue(func() *http.Transport {
|
||||
return NewBaseTransport()
|
||||
})
|
||||
|
||||
// FallbackTransport returns a shared *http.Transport singleton suitable for
|
||||
// use as a fallback when a transport decorator's Base is nil.
|
||||
// Unlike NewBaseTransport (which clones per call), this reuses a single
|
||||
// instance so that TCP connections and TLS sessions are pooled.
|
||||
func FallbackTransport() *http.Transport {
|
||||
return fallbackTransport()
|
||||
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
|
||||
//
|
||||
// By default it returns http.DefaultTransport — the stdlib-provided
|
||||
// process-wide singleton — so every HTTP client in the process shares one
|
||||
// TCP connection pool, TLS session cache, and HTTP/2 state. When
|
||||
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
|
||||
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
|
||||
// at most once.
|
||||
//
|
||||
// The returned RoundTripper MUST NOT be mutated. Callers that need a
|
||||
// customized transport should assert to *http.Transport and Clone() it.
|
||||
// Using a shared base is required so persistConn readLoop/writeLoop
|
||||
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
|
||||
// (~90s) fires.
|
||||
func SharedTransport() http.RoundTripper {
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// FallbackTransport returns a shared *http.Transport singleton. It is a
|
||||
// thin wrapper over SharedTransport retained so modules that were already
|
||||
// on the leak-free singleton path (internal/auth, internal/cmdutil
|
||||
// transport decorators) do not have to migrate. New code should prefer
|
||||
// SharedTransport and treat the base as an http.RoundTripper.
|
||||
func FallbackTransport() *http.Transport {
|
||||
if t, ok := SharedTransport().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
@@ -28,19 +28,65 @@ func TestDetectProxyEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBaseTransport_Default(t *testing.T) {
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := NewBaseTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Error("expected proxy func to be set when LARK_CLI_NO_PROXY is not set")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBaseTransport_NoProxy(t *testing.T) {
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := NewBaseTransport()
|
||||
if tr.Proxy != nil {
|
||||
t.Error("expected proxy func to be nil when LARK_CLI_NO_PROXY=1")
|
||||
tr := SharedTransport()
|
||||
if tr == http.DefaultTransport {
|
||||
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
||||
}
|
||||
ht, ok := tr.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", tr)
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("no-proxy transport should have Proxy == nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
a := SharedTransport()
|
||||
b := SharedTransport()
|
||||
if a != b {
|
||||
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
noProxy := SharedTransport()
|
||||
if noProxy == http.DefaultTransport {
|
||||
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
||||
}
|
||||
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
after := SharedTransport()
|
||||
if after != http.DefaultTransport {
|
||||
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
ht, ok := SharedTransport().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,35 +202,3 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBaseTransport_IsHTTPTransport(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := NewBaseTransport()
|
||||
|
||||
// Should be a valid *http.Transport that can be used
|
||||
var rt http.RoundTripper = tr
|
||||
_ = rt
|
||||
|
||||
// Verify it's not the same pointer as DefaultTransport (should be a clone)
|
||||
if tr == http.DefaultTransport {
|
||||
t.Error("NewBaseTransport should return a clone, not DefaultTransport itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBaseTransport_RespectsNoProxyEnv(t *testing.T) {
|
||||
// Simulate: user sets both system proxy and our disable flag
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
tr := NewBaseTransport()
|
||||
if tr.Proxy != nil {
|
||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||
}
|
||||
|
||||
// Clean up and verify proxy is restored
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr2 := NewBaseTransport()
|
||||
if tr2.Proxy == nil {
|
||||
t.Error("proxy should be enabled when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/util/strings_test.go
Normal file
53
internal/util/strings_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate", "hello world", 5, "hello"},
|
||||
{"empty", "", 5, ""},
|
||||
{"zero limit", "hello", 0, ""},
|
||||
{"CJK characters", "你好世界测试", 4, "你好世界"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStr(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStrWithEllipsis(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate with ellipsis", "hello world", 8, "hello..."},
|
||||
{"limit less than 3", "hello", 2, "he"},
|
||||
{"limit equals 3", "hello world", 3, "..."},
|
||||
{"empty", "", 5, ""},
|
||||
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
11
main_authsidecar.go
Normal file
11
main_authsidecar.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
|
||||
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
|
||||
)
|
||||
54
main_noauthsidecar.go
Normal file
54
main_noauthsidecar.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
// This file is the fail-closed guard for builds that do NOT include the
|
||||
// `authsidecar` tag. The sidecar credential-isolation feature is only
|
||||
// compiled in under that tag; deploying the plain build into an environment
|
||||
// that expects sidecar isolation would silently fall back to direct env
|
||||
// credential use — exactly the failure mode the feature is meant to prevent.
|
||||
//
|
||||
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
|
||||
// the variable. The operator either rebuilt without realizing (wrong
|
||||
// artifact) or the sandbox inherited the var by accident; both cases want
|
||||
// a loud startup error, not a mysterious token leak on the first API call.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
|
||||
// human-readable reason to stderr) when the environment asks for sidecar
|
||||
// isolation that this binary cannot provide. Factored out from init() so
|
||||
// tests can exercise the decision without actually calling os.Exit.
|
||||
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
|
||||
v := getenv(envvars.CliAuthProxy)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr,
|
||||
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
|
||||
"'authsidecar' build tag.\n"+
|
||||
"The sidecar credential-isolation feature is compiled out — "+
|
||||
"running would bypass isolation and\n"+
|
||||
"send any real credentials present in the environment directly "+
|
||||
"to the Lark API.\n\n"+
|
||||
"To fix, either:\n"+
|
||||
" - rebuild the CLI with: go build -tags authsidecar\n"+
|
||||
" - or unset %s if sidecar isolation is not required\n",
|
||||
envvars.CliAuthProxy, envvars.CliAuthProxy)
|
||||
return 2
|
||||
}
|
||||
52
main_noauthsidecar_test.go
Normal file
52
main_noauthsidecar_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
|
||||
if code != 0 {
|
||||
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr should be empty, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
|
||||
// a sandbox that expects sidecar isolation fails loudly at startup instead
|
||||
// of silently leaking credentials through the env provider path.
|
||||
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
env := func(k string) string {
|
||||
if k == envvars.CliAuthProxy {
|
||||
return "http://127.0.0.1:16384"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
code := checkNoAuthsidecarBuild(env, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
|
||||
}
|
||||
msg := stderr.String()
|
||||
for _, want := range []string{
|
||||
envvars.CliAuthProxy,
|
||||
"authsidecar", // build-tag name must appear so operators can act on it
|
||||
"rebuild",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
package-lock.json
generated
Normal file
84
package-lock.json
generated
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
|
||||
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.2.0",
|
||||
"fast-string-width": "^1.1.0",
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
|
||||
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
|
||||
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
|
||||
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.16",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -27,7 +27,11 @@
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"scripts/install.js",
|
||||
"scripts/install-wizard.js",
|
||||
"scripts/run.js",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
372
scripts/install-wizard.js
Normal file
372
scripts/install-wizard.js
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const messages = {
|
||||
zh: {
|
||||
setup: "正在设置 Feishu/Lark CLI...",
|
||||
step1: "正在安装 %s...",
|
||||
step1Upgrade: "正在升级 %s (v%s → v%s)...",
|
||||
step1Skip: "已安装 (v%s),跳过",
|
||||
step1Done: "已全局安装",
|
||||
step1Upgraded: "已升级到 v%s",
|
||||
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
|
||||
step2: "安装 AI Skills",
|
||||
step2Skip: "已安装,跳过",
|
||||
step2Spinner: "正在安装 Skills...",
|
||||
step2Done: "Skills 已安装",
|
||||
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
|
||||
step3: "正在配置应用...",
|
||||
step3NotFound: "未找到 lark-cli,终止",
|
||||
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
|
||||
step3Skip: "跳过应用配置",
|
||||
step3Done: "应用已配置",
|
||||
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
||||
step4: "授权",
|
||||
step4NotFound: "未找到 lark-cli,跳过授权",
|
||||
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
|
||||
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
||||
step4Done: "授权完成",
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
},
|
||||
en: {
|
||||
setup: "Setting up Feishu/Lark CLI...",
|
||||
step1: "Installing %s globally...",
|
||||
step1Upgrade: "Upgrading %s (v%s → v%s)...",
|
||||
step1Skip: "Already installed (v%s). Skipped",
|
||||
step1Done: "Installed globally",
|
||||
step1Upgraded: "Upgraded to v%s",
|
||||
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
|
||||
step2: "Install AI skills",
|
||||
step2Skip: "Already installed. Skipped",
|
||||
step2Spinner: "Installing skills...",
|
||||
step2Done: "Skills installed",
|
||||
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
|
||||
step3: "Configuring app...",
|
||||
step3NotFound: "lark-cli not found. Aborting",
|
||||
step3Found: "Found existing app (App ID: %s). Use this app?",
|
||||
step3Skip: "Skipped app configuration",
|
||||
step3Done: "App configured",
|
||||
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
||||
step4: "Authorization",
|
||||
step4NotFound: "lark-cli not found. Skipping authorization",
|
||||
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
|
||||
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
||||
step4Done: "Authorization complete",
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
|
||||
cancelled: "Installation cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleCancel(value, msg) {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function execCmd(cmd, args, opts) {
|
||||
if (isWindows) {
|
||||
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
|
||||
}
|
||||
return execFileSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
execCmd(cmd, args, { stdio: "inherit", ...opts });
|
||||
}
|
||||
|
||||
function runSilent(cmd, args, opts = {}) {
|
||||
return execCmd(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function runSilentAsync(cmd, args, opts = {}) {
|
||||
const actualCmd = isWindows ? "cmd.exe" : cmd;
|
||||
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(actualCmd, actualArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
}, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(template, ...values) {
|
||||
let i = 0;
|
||||
return template.replace(/%s/g, () => values[i++] ?? "");
|
||||
}
|
||||
|
||||
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
|
||||
function whichLarkCli() {
|
||||
try {
|
||||
const prefix = execFileSync("npm", ["prefix", "-g"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).toString().trim();
|
||||
const bin = isWindows
|
||||
? path.join(prefix, "lark-cli.cmd")
|
||||
: path.join(prefix, "bin", "lark-cli");
|
||||
if (fs.existsSync(bin)) return bin;
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
// Fallback to which/where if npm prefix lookup fails.
|
||||
try {
|
||||
const cmd = isWindows ? "where" : "which";
|
||||
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
|
||||
.toString()
|
||||
.split("\n")[0]
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
|
||||
function getLatestVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
|
||||
const ver = out.toString().trim();
|
||||
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare two semver strings. Returns true if a < b. */
|
||||
function semverLessThan(a, b) {
|
||||
const pa = a.replace(/-.*$/, "").split(".").map(Number);
|
||||
const pb = b.replace(/-.*$/, "").split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
|
||||
function getGloballyInstalledVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
|
||||
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
|
||||
return match ? match[1] : "unknown";
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether lark-cli config already exists. Returns app ID or null. */
|
||||
function getExistingAppId(binPath) {
|
||||
try {
|
||||
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
|
||||
const json = JSON.parse(out.toString());
|
||||
return json.appId || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse --lang from process.argv, returns "zh", "en", or null. */
|
||||
function parseLangArg() {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--lang" && args[i + 1]) {
|
||||
const val = args[i + 1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
if (args[i].startsWith("--lang=")) {
|
||||
const val = args[i].split("=")[1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stepSelectLang() {
|
||||
const fromArg = parseLangArg();
|
||||
if (fromArg) return fromArg;
|
||||
|
||||
const lang = await p.select({
|
||||
message: "请选择语言 / Select language",
|
||||
options: [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
],
|
||||
});
|
||||
return handleCancel(lang, messages.zh);
|
||||
}
|
||||
|
||||
async function stepInstallGlobally(msg) {
|
||||
const installedVer = getGloballyInstalledVersion();
|
||||
const latestVer = getLatestVersion();
|
||||
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
|
||||
|
||||
if (installedVer && !needsUpgrade) {
|
||||
p.log.info(fmt(msg.step1Skip, installedVer));
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
if (needsUpgrade) {
|
||||
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
|
||||
} else {
|
||||
s.start(fmt(msg.step1, PKG));
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
||||
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
||||
return needsUpgrade;
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step1Fail, PKG));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function skillsAlreadyInstalled() {
|
||||
try {
|
||||
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
return /^lark-/m.test(out.toString());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
s.stop(msg.step2Skip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
s.stop(msg.step2Done);
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepConfigInit(msg, lang) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step3);
|
||||
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
s.stop(msg.step3NotFound);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const appId = getExistingAppId(larkCli);
|
||||
s.stop(msg.step3);
|
||||
|
||||
if (appId) {
|
||||
const reuse = await p.confirm({
|
||||
message: fmt(msg.step3Found, appId),
|
||||
});
|
||||
if (handleCancel(reuse, msg) && reuse) {
|
||||
p.log.info(msg.step3Skip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
run(larkCli, ["config", "init", "--new", "--lang", lang]);
|
||||
p.log.success(msg.step3Done);
|
||||
} catch (_) {
|
||||
p.log.error(msg.step3Fail);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepAuthLogin(msg) {
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
p.log.warn(msg.step4NotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
const yes = await p.confirm({
|
||||
message: msg.step4Confirm,
|
||||
});
|
||||
if (p.isCancel(yes)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!yes) {
|
||||
p.log.info(msg.step4Skip);
|
||||
return;
|
||||
}
|
||||
|
||||
p.log.step(msg.step4);
|
||||
try {
|
||||
run(larkCli, ["auth", "login"]);
|
||||
p.log.success(msg.step4Done);
|
||||
} catch (_) {
|
||||
p.log.warn(msg.step4Fail);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const lang = await stepSelectLang();
|
||||
const msg = messages[lang];
|
||||
|
||||
p.intro(msg.setup);
|
||||
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
|
||||
p.outro(msg.done);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const { execFileSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
const VERSION = require("../package.json").version;
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
|
||||
@@ -43,13 +43,16 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
const args = [
|
||||
"--fail", "--location", "--silent", "--show-error",
|
||||
"--connect-timeout", "10", "--max-time", "120",
|
||||
"--output", destPath,
|
||||
];
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
||||
args.push(url);
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
}
|
||||
|
||||
function install() {
|
||||
@@ -64,12 +67,12 @@ function install() {
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
@@ -85,6 +88,16 @@ function install() {
|
||||
}
|
||||
}
|
||||
|
||||
// When triggered as a postinstall hook under npx, skip the binary download.
|
||||
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
||||
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
||||
const isNpxPostinstall =
|
||||
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
||||
|
||||
if (isNpxPostinstall) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,21 +41,32 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(bin)) {
|
||||
console.error(
|
||||
`Error: lark-cli binary not found at ${bin}\n\n` +
|
||||
`This usually means the postinstall script was skipped.\n` +
|
||||
`Common causes:\n` +
|
||||
` - npm is configured with ignore-scripts=true\n` +
|
||||
` - The postinstall download failed\n\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Intercept "install" subcommand — run the setup wizard directly,
|
||||
// bypassing the native binary (which may not exist yet under npx).
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "install") {
|
||||
require("./install-wizard.js");
|
||||
} else {
|
||||
// Auto-download binary if missing (e.g. npx skipped postinstall).
|
||||
if (!fs.existsSync(bin)) {
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, LARK_CLI_RUN: "true" },
|
||||
});
|
||||
} catch (_) {
|
||||
console.error(
|
||||
`\nFailed to auto-install lark-cli binary.\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
try {
|
||||
execFileSync(bin, args, { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:copy"},
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:create"},
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,12 +20,16 @@ import (
|
||||
)
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
||||
}
|
||||
|
||||
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) {
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
||||
}
|
||||
|
||||
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if authTypes != nil {
|
||||
shortcut.AuthTypes = authTypes
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
@@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
@@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
||||
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["copied"] != true {
|
||||
t.Fatalf("copied = %#v, want true", data["copied"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "base_token"); got != "app_new" {
|
||||
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
t.Run("create bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create user", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
@@ -151,6 +429,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err should not mention array: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Fatalf("stdout=%q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -259,7 +618,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
||||
@@ -277,7 +636,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
||||
@@ -860,7 +1219,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) {
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_big_1",
|
||||
"block_size": float64(8 * 1024 * 1024),
|
||||
"block_num": float64(3),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
t.Fatalf("prepare body=%s", prepareBody)
|
||||
}
|
||||
|
||||
firstPartBody := string(partStubs[0].CapturedBody)
|
||||
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
|
||||
!strings.Contains(firstPartBody, "upload_big_1") ||
|
||||
!strings.Contains(firstPartBody, `name="seq"`) ||
|
||||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
|
||||
!strings.Contains(firstPartBody, `name="size"`) ||
|
||||
!strings.Contains(firstPartBody, "8388608") {
|
||||
t.Fatalf("first part body=%s", firstPartBody)
|
||||
}
|
||||
|
||||
lastPartBody := string(partStubs[2].CapturedBody)
|
||||
if !strings.Contains(lastPartBody, `name="seq"`) ||
|
||||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
|
||||
!strings.Contains(lastPartBody, `name="size"`) ||
|
||||
!strings.Contains(lastPartBody, "4194305") {
|
||||
t.Fatalf("last part body=%s", lastPartBody)
|
||||
}
|
||||
|
||||
finishBody := string(finishStub.CapturedBody)
|
||||
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
|
||||
!strings.Contains(finishBody, `"block_num":3`) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
@@ -904,6 +1418,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
@@ -1021,7 +1566,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if runtime.Bool("without-content") {
|
||||
body["without_content"] = true
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(body)
|
||||
Body(buildBaseCreateBody(runtime))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
@@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "copied": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
@@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
@@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func extractBasePermissionToken(base map[string]interface{}) string {
|
||||
for _, key := range []string{"base_token", "app_token"} {
|
||||
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate == nil {
|
||||
t.Fatal("expected validate hook")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("create validate err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = common.ParseJSON([]byte(raw), &body)
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
return parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
}
|
||||
|
||||
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
|
||||
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
|
||||
@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
@@ -37,7 +37,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
@@ -52,7 +52,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
@@ -61,15 +61,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id")).
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
Body(map[string]interface{}{
|
||||
@@ -82,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
@@ -102,7 +131,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
@@ -124,6 +153,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
@@ -151,6 +183,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
@@ -209,53 +249,111 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", baseAttachmentParentType)
|
||||
fd.AddField("parent_node", baseToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: &parentNode,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, readErr := f.Read(buf)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEFromContent(content []byte) string {
|
||||
if len(content) == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return "image/png"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
|
||||
return "image/gif"
|
||||
}
|
||||
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
|
||||
return "image/webp"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("%PDF-")) {
|
||||
return "application/pdf"
|
||||
}
|
||||
if looksLikeText(content) {
|
||||
return "text/plain"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func looksLikeText(content []byte) bool {
|
||||
if !utf8.Valid(content) {
|
||||
return false
|
||||
}
|
||||
for _, r := range string(content) {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
136
shortcuts/base/record_upload_attachment_test.go
Normal file
136
shortcuts/base/record_upload_attachment_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
type attachmentTestFileIO struct {
|
||||
openFile fileio.File
|
||||
openErr error
|
||||
}
|
||||
|
||||
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
|
||||
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
|
||||
return attachmentTestFileInfo{}, nil
|
||||
}
|
||||
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type attachmentTestFileInfo struct{}
|
||||
|
||||
func (attachmentTestFileInfo) Size() int64 { return 0 }
|
||||
func (attachmentTestFileInfo) IsDir() bool { return false }
|
||||
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
|
||||
|
||||
type attachmentTestFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func newAttachmentTestFile(content []byte) attachmentTestFile {
|
||||
return attachmentTestFile{Reader: bytes.NewReader(content)}
|
||||
}
|
||||
|
||||
func (attachmentTestFile) Close() error { return nil }
|
||||
|
||||
type attachmentReadErrorFile struct{}
|
||||
|
||||
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
|
||||
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
|
||||
func (attachmentReadErrorFile) Close() error { return nil }
|
||||
|
||||
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
|
||||
|
||||
got, err := detectAttachmentMIMEType(fio, "note", "note")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for open failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want wrapped read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for read failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", content: nil, want: "application/octet-stream"},
|
||||
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
|
||||
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
|
||||
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
|
||||
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
|
||||
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
|
||||
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
|
||||
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
|
||||
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
|
||||
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
|
||||
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectAttachmentMIMEFromContent(tt.content)
|
||||
if got != tt.want {
|
||||
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
|
||||
}
|
||||
|
||||
func validateViewCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseObjectList(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func validateViewJSONObject(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONValue(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func executeViewList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetGroup,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetSort,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
|
||||
@@ -54,6 +54,7 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
|
||||
"start_time": fmt.Sprintf("%d", startTime),
|
||||
"end_time": fmt.Sprintf("%d", endTime),
|
||||
}, nil)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ var CalendarCreate = common.Shortcut{
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
||||
nil, eventData)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,11 +222,13 @@ var CalendarCreate = common.Shortcut{
|
||||
"attendees": attendees,
|
||||
"need_notification": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.RawAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
map[string]interface{}{"need_notification": false}, nil)
|
||||
rollbackErr = wrapPredefinedError(rollbackErr)
|
||||
if rollbackErr != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ var CalendarFreebusy = common.Shortcut{
|
||||
"user_id": userId,
|
||||
"need_rsvp_status": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user