mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34ee5bef9 |
@@ -6,6 +6,3 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
116
.github/workflows/arch-audit.yml
vendored
@@ -1,116 +0,0 @@
|
||||
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
334
.github/workflows/ci.yml
vendored
@@ -1,334 +0,0 @@
|
||||
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
|
||||
83
.github/workflows/cli-e2e.yml
vendored
Normal file
83
.github/workflows/cli-e2e.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
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
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
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
|
||||
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
|
||||
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
|
||||
58
.github/workflows/coverage.yml
vendored
Normal file
58
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
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
Normal file
28
.github/workflows/gitleaks.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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
Normal file
26
.github/workflows/license-header.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
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
Normal file
60
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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
Normal file
43
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
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 ./...
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,5 +36,3 @@ tests/mail/reports/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
@@ -70,14 +70,6 @@ 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 ──
|
||||
@@ -108,16 +100,6 @@ 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,11 +18,9 @@ make test # Full: vet + unit + integration
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
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`
|
||||
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`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
@@ -78,26 +76,3 @@ 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 |
|
||||
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -2,125 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.17] - 2026-04-22
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
|
||||
- **drive**: Add `+apply-permission` to request doc access (#588)
|
||||
- Support record share link (#466)
|
||||
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
|
||||
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Add default-table follow-up hint to `base-create` (#600)
|
||||
- Skip flag-completion registration outside completion path (#598)
|
||||
- Add `record-share-link-create` in `SKILL.md` (#597)
|
||||
- **mail**: Remove leftover conflict marker in skill docs (#594)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
|
||||
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
|
||||
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
|
||||
|
||||
## [v1.0.16] - 2026-04-21
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Support large email attachments (#537)
|
||||
- **mail**: Add draft preview URL to draft operations (#438)
|
||||
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
|
||||
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
|
||||
- **calendar**: Support event share link and error details (#583)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
|
||||
- **docs**: Validate `--selection-by-title` format early (#256)
|
||||
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
|
||||
- **auth**: Simplify scope reporting in login flow (#582)
|
||||
|
||||
## [v1.0.15] - 2026-04-20
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add float image shortcuts (#494)
|
||||
- **approval**: Document `remind` and `initiated` methods in skill (#554)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Preserve attachment metadata on base uploads (#563)
|
||||
- **base**: Fix role view and record default permission on edit (#530)
|
||||
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
|
||||
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
|
||||
- **install**: Refine install wizard messages (#529)
|
||||
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
|
||||
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
|
||||
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
|
||||
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
|
||||
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
|
||||
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
|
||||
|
||||
### CI
|
||||
|
||||
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
|
||||
|
||||
## [v1.0.12] - 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
|
||||
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
|
||||
- **mail**: Return recall hints for sent emails when recall is available (#481)
|
||||
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
|
||||
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
@@ -464,12 +345,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
|
||||
@@ -30,7 +30,7 @@ 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 |
|
||||
@@ -38,7 +38,6 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 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
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -57,10 +57,6 @@ 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
|
||||
|
||||
@@ -83,7 +79,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
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)")
|
||||
@@ -100,7 +96,10 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = 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,24 +180,6 @@ 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,
|
||||
|
||||
@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
|
||||
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -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)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %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 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",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but 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,12 +69,6 @@ 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 scope
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
@@ -136,6 +136,7 @@ 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
|
||||
@@ -189,13 +190,13 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
|
||||
@@ -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,10 +376,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -391,18 +392,15 @@ 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, "授权成功") {
|
||||
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)
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization completed, but 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"},
|
||||
@@ -471,13 +469,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:",
|
||||
},
|
||||
@@ -492,10 +490,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:",
|
||||
},
|
||||
@@ -510,9 +508,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;",
|
||||
@@ -621,9 +619,10 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -635,12 +634,6 @@ 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)
|
||||
}
|
||||
@@ -750,7 +743,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;",
|
||||
@@ -778,18 +771,16 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Login 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
129
cmd/build.go
@@ -1,129 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu 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...")
|
||||
@@ -29,11 +29,11 @@ type initMsg struct {
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
|
||||
}
|
||||
|
||||
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",
|
||||
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",
|
||||
|
||||
@@ -48,12 +48,12 @@ 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,
|
||||
"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,
|
||||
|
||||
@@ -3,38 +3,15 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
|
||||
// is a build-time policy — when true, --profile stays parseable but is marked
|
||||
// hidden from help and shell completion.
|
||||
// actual Cobra command tree.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
HideProfile bool
|
||||
Profile string
|
||||
}
|
||||
|
||||
// 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.
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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
18
cmd/init.go
@@ -1,18 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
64
cmd/root.go
64
cmd/root.go
@@ -14,6 +14,15 @@ 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"
|
||||
@@ -21,6 +30,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -85,13 +95,38 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
f := cmdutil.NewDefault(inv)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -155,12 +190,6 @@ func isCompletionCommand(args []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
@@ -248,19 +277,10 @@ 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. 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`.
|
||||
// when a command has tips set via cmdutil.SetTips.
|
||||
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,12 +135,10 @@ 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 := cmdutil.NewDefault(
|
||||
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
||||
cmdutil.InvocationContext{Profile: profile},
|
||||
)
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
|
||||
@@ -196,28 +196,3 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDisabled bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, true},
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
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"
|
||||
@@ -21,7 +19,6 @@ import (
|
||||
// SchemaOptions holds all inputs for the schema command.
|
||||
type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
@@ -44,7 +41,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{}, mode core.StrictMode) {
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
@@ -58,13 +55,9 @@ func printResourceList(w io.Writer, spec map[string]interface{}, mode core.Stric
|
||||
|
||||
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")
|
||||
@@ -366,7 +359,6 @@ 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)
|
||||
}
|
||||
@@ -375,9 +367,9 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.ValidArgsFunction = completeSchemaPath
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -387,86 +379,78 @@ 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(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, ".")
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
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+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
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:], ".")
|
||||
|
||||
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+".")
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return 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
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
@@ -485,9 +469,9 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
printResourceList(out, spec)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
output.PrintJson(out, spec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -508,7 +492,6 @@ 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")
|
||||
@@ -517,26 +500,13 @@ 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 {
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
@@ -555,67 +525,3 @@ 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,49 +182,3 @@ 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,10 +24,6 @@ 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 {
|
||||
@@ -42,15 +38,11 @@ func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Comma
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerService(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 == "" {
|
||||
@@ -78,11 +70,11 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
registerResource(svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
@@ -95,7 +87,7 @@ func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spe
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
registerMethod(res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,16 +101,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
|
||||
}
|
||||
@@ -128,16 +120,12 @@ func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
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))
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -171,7 +159,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
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)")
|
||||
@@ -189,7 +177,11 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
|
||||
_ = 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,24 +121,6 @@ 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,10 +3,7 @@
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -14,28 +11,12 @@ var (
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Providers are consulted in registration order.
|
||||
// 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,32 +37,6 @@ 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
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// 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{})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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 }
|
||||
@@ -1,103 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// 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{})
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// 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,31 +27,6 @@ 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 response received\n")
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
|
||||
refreshToken := getStr(data, "refresh_token")
|
||||
tokenExpiresIn := getInt(data, "expires_in", 7200)
|
||||
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Cobra keeps completion callbacks in a package-global map keyed by
|
||||
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
|
||||
// outlive the command itself. Skip registration when the current invocation
|
||||
// will not serve a completion request.
|
||||
var flagCompletionsDisabled atomic.Bool
|
||||
|
||||
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
|
||||
// registering and no-op. Typically set once at process start.
|
||||
func SetFlagCompletionsDisabled(disabled bool) {
|
||||
flagCompletionsDisabled.Store(disabled)
|
||||
}
|
||||
|
||||
// FlagCompletionsDisabled reports the current switch state.
|
||||
func FlagCompletionsDisabled() bool {
|
||||
return flagCompletionsDisabled.Load()
|
||||
}
|
||||
|
||||
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
|
||||
// and honors the package switch. The underlying error is swallowed to match
|
||||
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
|
||||
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
|
||||
if flagCompletionsDisabled.Load() {
|
||||
return
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected default false")
|
||||
}
|
||||
SetFlagCompletionsDisabled(true)
|
||||
if !FlagCompletionsDisabled() {
|
||||
t.Fatal("expected true after Set(true)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(false)
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected false after Set(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// When disabled, a *cobra.Command must be collectable after the caller drops
|
||||
// its reference — i.e. the wrapper did not touch cobra's global map.
|
||||
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
const N = 5
|
||||
var collected atomic.Int32
|
||||
func() {
|
||||
for range N {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
|
||||
}
|
||||
}()
|
||||
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
|
||||
for range 30 {
|
||||
runtime.GC()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := collected.Load(); int(got) != N {
|
||||
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When enabled, the registered completion must be reachable via cobra.
|
||||
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(false)
|
||||
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
want := []cobra.Completion{"a", "b"}
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return want, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
fn, ok := cmd.GetFlagCompletionFunc("foo")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func to be registered")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected completion result: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ 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"
|
||||
@@ -32,24 +34,27 @@ import (
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
IOStreams: streams,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Keychain: f.Keychain,
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
@@ -88,11 +93,11 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
@@ -117,7 +122,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -129,16 +134,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
var sdkTransport http.RoundTripper = util.NewBaseTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Keychain keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
|
||||
@@ -6,10 +6,14 @@ 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"
|
||||
@@ -59,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(nil, InvocationContext{Profile: "target"})
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
@@ -99,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(nil, InvocationContext{Profile: "missing"})
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
@@ -116,6 +120,22 @@ 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")
|
||||
@@ -124,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
f := NewDefault(InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
@@ -144,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -169,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -197,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
f := NewDefault(InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
@@ -212,3 +232,170 @@ 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,12 +4,11 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
fn := cachedHttpClientFunc()
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -29,7 +28,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
fn := cachedHttpClientFunc()
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -37,7 +36,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
fn := cachedHttpClientFunc()
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -123,22 +122,9 @@ 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, formatFormFieldValue(v))
|
||||
fd.AddField(k, fmt.Sprintf("%v", 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,40 +336,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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)
|
||||
RegisterFlagCompletion(cmd, "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)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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,12 +3,7 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
import "io"
|
||||
|
||||
// IOStreams provides the standard input/output/error streams.
|
||||
// Commands should use these instead of os.Stdin/Stdout/Stderr
|
||||
@@ -19,45 +14,3 @@ 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
|
||||
}
|
||||
|
||||
81
internal/cmdutil/retry_transport_test.go
Normal file
81
internal/cmdutil/retry_transport_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -21,21 +14,12 @@ import (
|
||||
const (
|
||||
HeaderSource = "X-Cli-Source"
|
||||
HeaderVersion = "X-Cli-Version"
|
||||
HeaderBuild = "X-Cli-Build"
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
HeaderUserAgent = "User-Agent"
|
||||
|
||||
// BuildKindOfficial / BuildKindExtended / BuildKindUnknown are the values
|
||||
// reported in the X-Cli-Build header; see DetectBuildKind for semantics.
|
||||
BuildKindOfficial = "official"
|
||||
BuildKindExtended = "extended"
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -48,108 +32,10 @@ func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
h.Set(HeaderSource, SourceValue)
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
return h
|
||||
}
|
||||
|
||||
var (
|
||||
buildKindOnce sync.Once
|
||||
buildKindVal string
|
||||
)
|
||||
|
||||
// DetectBuildKind reports whether this binary is the official CLI, an
|
||||
// extended/repackaged build, or unknown. The result is cached via sync.Once
|
||||
// so it is computed only on the first call.
|
||||
//
|
||||
// IMPORTANT: must NOT be called from any package init(). Go's init ordering
|
||||
// follows the import graph; ISV providers registered via blank import may not
|
||||
// have run yet, which would misclassify an extended build as official. Call
|
||||
// only when handling an actual request (e.g. from BaseSecurityHeaders).
|
||||
func DetectBuildKind() string {
|
||||
buildKindOnce.Do(func() {
|
||||
buildKindVal = computeBuildKind()
|
||||
})
|
||||
return buildKindVal
|
||||
}
|
||||
|
||||
// computeBuildKind performs the actual detection without any caching.
|
||||
// Exposed for tests. Gathers runtime/global inputs and delegates the pure
|
||||
// branching logic to classifyBuild so that logic can be unit-tested without
|
||||
// mutating process-wide provider registries.
|
||||
func computeBuildKind() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
mainPath := ""
|
||||
if ok {
|
||||
mainPath = info.Main.Path
|
||||
}
|
||||
|
||||
credProviders := credential.Providers()
|
||||
creds := make([]any, len(credProviders))
|
||||
for i, p := range credProviders {
|
||||
creds[i] = p
|
||||
}
|
||||
|
||||
var tp any
|
||||
if p := exttransport.GetProvider(); p != nil {
|
||||
tp = p
|
||||
}
|
||||
var fp any
|
||||
if p := fileio.GetProvider(); p != nil {
|
||||
fp = p
|
||||
}
|
||||
return classifyBuild(mainPath, ok, creds, tp, fp)
|
||||
}
|
||||
|
||||
// classifyBuild is the pure classification logic used by computeBuildKind.
|
||||
// Callers supply concrete values so every branch is reachable from tests
|
||||
// without touching debug.ReadBuildInfo or the extension registries.
|
||||
//
|
||||
// Priority order mirrors the design doc:
|
||||
// 1. no build info → unknown
|
||||
// 2. main module path not the official one → extended (ISV wrapper)
|
||||
// 3. any non-builtin provider (credential / transport / fileio) → extended
|
||||
// 4. otherwise → official
|
||||
func classifyBuild(mainPath string, haveBuildInfo bool, credProviders []any, transportProvider, fileioProvider any) string {
|
||||
if !haveBuildInfo {
|
||||
return BuildKindUnknown
|
||||
}
|
||||
if mainPath != "" && mainPath != officialModulePath {
|
||||
return BuildKindExtended
|
||||
}
|
||||
for _, p := range credProviders {
|
||||
if !isBuiltinProvider(p) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
}
|
||||
if transportProvider != nil && !isBuiltinProvider(transportProvider) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
if fileioProvider != nil && !isBuiltinProvider(fileioProvider) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
return BuildKindOfficial
|
||||
}
|
||||
|
||||
// isBuiltinProvider reports whether p is declared under the official module
|
||||
// path. Third-party providers live under their own module and fail this check.
|
||||
// Using reflect.PkgPath makes this robust against Name() spoofing since
|
||||
// package paths are fixed at compile time.
|
||||
func isBuiltinProvider(p any) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
t := reflect.TypeOf(p)
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
pkg := t.PkgPath()
|
||||
return pkg == officialModulePath || strings.HasPrefix(pkg, officialModulePath+"/")
|
||||
}
|
||||
|
||||
// ── Context utilities ──
|
||||
|
||||
type ctxKey string
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sidecarcred "github.com/larksuite/cli/extension/credential/sidecar"
|
||||
sidecartrans "github.com/larksuite/cli/extension/transport/sidecar"
|
||||
)
|
||||
|
||||
// TestIsBuiltinProvider_SidecarProviders locks the classification for the
|
||||
// sidecar-mode providers enumerated in design doc §3.3.2 as "官方自带". These
|
||||
// types only compile when the `authsidecar` build tag is active, so the test
|
||||
// is guarded by the same tag.
|
||||
func TestIsBuiltinProvider_SidecarProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider any
|
||||
}{
|
||||
{"sidecar credential provider", &sidecarcred.Provider{}},
|
||||
{"sidecar transport provider", &sidecartrans.Provider{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if !isBuiltinProvider(tc.provider) {
|
||||
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
envcred "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isBuiltinProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// cmdutilLocalProvider has PkgPath under the official module
|
||||
// ("github.com/larksuite/cli/internal/cmdutil") and should be classified
|
||||
// as builtin.
|
||||
type cmdutilLocalProvider struct{}
|
||||
|
||||
// Name intentionally returns a value that mimics an external provider; the
|
||||
// PkgPath-based classifier must ignore it. See TestIsBuiltinProvider_PkgPathNotSpoofableByName.
|
||||
func (cmdutilLocalProvider) Name() string { return "external-spoofed-provider" }
|
||||
func (cmdutilLocalProvider) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (cmdutilLocalProvider) ResolveToken(context.Context, credential.TokenSpec) (*credential.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_Nil(t *testing.T) {
|
||||
if isBuiltinProvider(nil) {
|
||||
t.Fatal("isBuiltinProvider(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_TypeUnderOfficialModule(t *testing.T) {
|
||||
if !isBuiltinProvider(&cmdutilLocalProvider{}) {
|
||||
t.Fatal("type under github.com/larksuite/cli/... should be builtin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_StdlibTypeIsNotBuiltin(t *testing.T) {
|
||||
// A standard library type has PkgPath "net/http" — outside official module.
|
||||
// This covers the non-builtin branch, which we cannot trigger from inside
|
||||
// this test file using a locally-defined type.
|
||||
if isBuiltinProvider(&http.Server{}) {
|
||||
t.Fatal("stdlib type classified as builtin, PkgPath check is broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_PkgPathNotSpoofableByName(t *testing.T) {
|
||||
// Name() returns a string, but classification uses reflect.Type.PkgPath
|
||||
// which is compile-time fixed. The local type returns a name that looks
|
||||
// like an ISV provider; it must still classify as builtin.
|
||||
p := &cmdutilLocalProvider{}
|
||||
if p.Name() != "external-spoofed-provider" {
|
||||
t.Fatalf("sanity check: Name() = %q, spoof value lost", p.Name())
|
||||
}
|
||||
if !isBuiltinProvider(p) {
|
||||
t.Fatal("isBuiltinProvider should decide by PkgPath, not Name()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBuiltinProvider_NonPointerValues covers the non-pointer reflect branch.
|
||||
// The existing tests only exercise pointer receivers (&T{}); when a provider
|
||||
// is passed by value the reflect.Kind is not Ptr and t.Elem() is skipped.
|
||||
func TestIsBuiltinProvider_NonPointerValues(t *testing.T) {
|
||||
if !isBuiltinProvider(cmdutilLocalProvider{}) {
|
||||
t.Fatal("non-pointer local type should be builtin (PkgPath still under official module)")
|
||||
}
|
||||
// http.Server as a non-pointer — PkgPath "net/http", not under official.
|
||||
if isBuiltinProvider(http.Server{}) {
|
||||
t.Fatal("non-pointer stdlib type should not be builtin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBuiltinProvider_RealBuiltinProviders locks down the classification
|
||||
// for the concrete providers enumerated in design doc §3.3.2 as "官方自带":
|
||||
// env credential provider and local fileio provider. If any of these is
|
||||
// moved out of the official module tree in the future, this test must flip
|
||||
// red so the new package path is explicitly considered.
|
||||
//
|
||||
// The sidecar providers (extension/credential/sidecar and
|
||||
// extension/transport/sidecar) are guarded by the `authsidecar` build tag
|
||||
// and covered in secheader_sidecar_test.go under that tag.
|
||||
func TestIsBuiltinProvider_RealBuiltinProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider any
|
||||
}{
|
||||
{"env credential provider", &envcred.Provider{}},
|
||||
{"local fileio provider", &localfileio.Provider{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if !isBuiltinProvider(tc.provider) {
|
||||
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeBuildKind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComputeBuildKind_ReturnsKnownValue(t *testing.T) {
|
||||
// Under `go test`, Main.Path is typically the module being tested
|
||||
// ("github.com/larksuite/cli"); the concrete return may still be
|
||||
// official, extended, or unknown depending on Main.Path and the
|
||||
// registered providers. Just assert it's one of the defined values.
|
||||
got := computeBuildKind()
|
||||
switch got {
|
||||
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
|
||||
default:
|
||||
t.Fatalf("computeBuildKind() = %q, want one of official/extended/unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyBuild — pure branching logic
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These tests cover every branch of classifyBuild with explicit inputs,
|
||||
// which is impossible from computeBuildKind alone because debug.ReadBuildInfo
|
||||
// and the process-wide provider registries can't be reshaped in a test.
|
||||
|
||||
func TestClassifyBuild_NoBuildInfo_ReturnsUnknown(t *testing.T) {
|
||||
if got := classifyBuild("", false, nil, nil, nil); got != BuildKindUnknown {
|
||||
t.Fatalf("classifyBuild(haveBuildInfo=false) = %q, want %q", got, BuildKindUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_ExtendedMainPath_ReturnsExtended(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/acme/lark-cli-wrapper",
|
||||
"example.com/isv/lark",
|
||||
"gitlab.mycorp.internal/tools/lark-cli-fork",
|
||||
}
|
||||
for _, mp := range cases {
|
||||
t.Run(mp, func(t *testing.T) {
|
||||
if got := classifyBuild(mp, true, nil, nil, nil); got != BuildKindExtended {
|
||||
t.Fatalf("mainPath=%q classifyBuild = %q, want %q", mp, got, BuildKindExtended)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_OfficialMainPath_NoProviders_ReturnsOfficial(t *testing.T) {
|
||||
if got := classifyBuild(officialModulePath, true, nil, nil, nil); got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild(official, no providers) = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_EmptyMainPath_DoesNotTriggerExtended(t *testing.T) {
|
||||
// An empty Main.Path (rare, e.g. `go run` pre-1.18) must not be treated
|
||||
// as extended by itself — the classifier falls through to provider checks.
|
||||
if got := classifyBuild("", true, nil, nil, nil); got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild(empty mainPath, no providers) = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinCredentialProvider_ReturnsExtended(t *testing.T) {
|
||||
// Any non-builtin credential provider flips the verdict to extended.
|
||||
got := classifyBuild(officialModulePath, true, []any{&http.Server{}}, nil, nil)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external credential = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_MixedCredentialProviders_ExtendedWins(t *testing.T) {
|
||||
// Even if most providers are builtin, a single external one decides.
|
||||
providers := []any{&cmdutilLocalProvider{}, &http.Server{}}
|
||||
if got := classifyBuild(officialModulePath, true, providers, nil, nil); got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild mixed providers = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinTransportProvider_ReturnsExtended(t *testing.T) {
|
||||
got := classifyBuild(officialModulePath, true, nil, &http.Server{}, nil)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external transport = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinFileioProvider_ReturnsExtended(t *testing.T) {
|
||||
got := classifyBuild(officialModulePath, true, nil, nil, &http.Server{})
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external fileio = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_AllBuiltinProviders_ReturnsOfficial(t *testing.T) {
|
||||
// All three slots filled with builtin providers must still classify as official.
|
||||
got := classifyBuild(
|
||||
officialModulePath, true,
|
||||
[]any{&cmdutilLocalProvider{}},
|
||||
&cmdutilLocalProvider{},
|
||||
&cmdutilLocalProvider{},
|
||||
)
|
||||
if got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild all-builtin = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyBuild_MainPathPriorityOverProviders documents that the main
|
||||
// module path takes precedence: even with only builtin providers, a non-
|
||||
// official main path still yields extended.
|
||||
func TestClassifyBuild_MainPathPriorityOverProviders(t *testing.T) {
|
||||
got := classifyBuild(
|
||||
"github.com/acme/lark-wrapper", true,
|
||||
[]any{&cmdutilLocalProvider{}},
|
||||
&cmdutilLocalProvider{},
|
||||
&cmdutilLocalProvider{},
|
||||
)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("main-path override failed: got %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectBuildKind — sync.Once caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetectBuildKind_StableAcrossCalls(t *testing.T) {
|
||||
a := DetectBuildKind()
|
||||
b := DetectBuildKind()
|
||||
if a != b {
|
||||
t.Fatalf("DetectBuildKind() returned different values on repeat: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
v := h.Get(HeaderBuild)
|
||||
if v == "" {
|
||||
t.Fatal("BaseSecurityHeaders missing X-Cli-Build header")
|
||||
}
|
||||
switch v {
|
||||
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
|
||||
default:
|
||||
t.Fatalf("X-Cli-Build = %q, want one of official/extended/unknown", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
|
||||
if h.Get(key) == "" {
|
||||
t.Errorf("BaseSecurityHeaders missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,24 +72,6 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. Used in the SDK transport chain,
|
||||
// where SecurityHeaderTransport is not installed, to prevent extensions from
|
||||
// tampering with the build classification. The direct HTTP chain is already
|
||||
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
|
||||
type BuildHeaderTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
// headers into every request. Shortcut headers are read from the request context.
|
||||
type SecurityHeaderTransport struct {
|
||||
@@ -122,47 +104,20 @@ 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 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
|
||||
// The built-in chain always executes and cannot be skipped or overridden.
|
||||
// The original request context is restored after PreRoundTrip 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
|
||||
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
}
|
||||
|
||||
// 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.
|
||||
// RoundTrip calls PreRoundTrip, restores the original context, executes
|
||||
// the built-in chain, then calls the post hook if non-nil.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
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.Clone(origCtx) // isolate caller's request before extension mutations
|
||||
post := m.Ext.PreRoundTrip(req)
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
@@ -182,5 +137,5 @@ func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr}
|
||||
}
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
// 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()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.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 → BuildHeader → 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)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.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()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.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)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
|
||||
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
|
||||
// value before the request leaves the process.
|
||||
type buildTamperingInterceptor struct{}
|
||||
|
||||
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Del(HeaderBuild)
|
||||
req.Header.Set(HeaderBuild, "ext-tampered-build")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{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()
|
||||
|
||||
if receivedBuild == "ext-tampered-build" {
|
||||
t.Fatalf("%s = %q, extension tampering leaked to network", HeaderBuild, receivedBuild)
|
||||
}
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
// no extension is registered, BuildHeaderTransport writes X-Cli-Build.
|
||||
func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
transport := &BuildHeaderTransport{Base: http.DefaultTransport}
|
||||
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()
|
||||
|
||||
if receivedBuild == "" {
|
||||
t.Fatalf("%s header missing, BuildHeaderTransport did not inject", HeaderBuild)
|
||||
}
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||
// the transport still sets X-Cli-Build and routes the request through
|
||||
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
transport := &BuildHeaderTransport{Base: nil}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request via nil-Base transport failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q (header must be set even on nil-Base path)",
|
||||
HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,14 +21,11 @@ import (
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain func() keychain.KeychainAccess
|
||||
keychain keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
if kc == nil {
|
||||
kc = keychain.Default
|
||||
}
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
@@ -39,7 +36,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,7 +12,6 @@ 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{}
|
||||
@@ -100,7 +99,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
|
||||
@@ -11,8 +11,4 @@ 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
|
||||
)
|
||||
|
||||
@@ -38,17 +38,6 @@ const (
|
||||
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
|
||||
|
||||
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
|
||||
LarkErrDrivePermApplyRateLimit = 1063006
|
||||
// Drive permission apply: request is not applicable for this document
|
||||
// (e.g. the document is configured to disallow access requests, or the
|
||||
// caller already holds the requested permission, or the target type does
|
||||
// not accept apply operations).
|
||||
LarkErrDrivePermApplyNotApplicable = 1063007
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -84,20 +73,6 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
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"
|
||||
|
||||
// drive permission-apply specific guidance
|
||||
case LarkErrDrivePermApplyRateLimit:
|
||||
return ExitAPI, "rate_limit",
|
||||
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
|
||||
case LarkErrDrivePermApplyNotApplicable:
|
||||
return ExitAPI, "invalid_params",
|
||||
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
@@ -40,27 +40,6 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "drive permission apply rate limit",
|
||||
code: LarkErrDrivePermApplyRateLimit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "rate_limit",
|
||||
wantHint: "5 times per day",
|
||||
},
|
||||
{
|
||||
name: "drive permission apply not applicable",
|
||||
code: LarkErrDrivePermApplyNotApplicable,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "does not accept a permission-apply request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -63,9 +63,5 @@
|
||||
"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,20 +146,11 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate installs skills, trying the .well-known source first and
|
||||
// falling back to the GitHub repo on failure or timeout.
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
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 {
|
||||
@@ -168,7 +159,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-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.SharedTransport(),
|
||||
Transport: util.NewBaseTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,47 +72,31 @@ func WarnIfProxied(w io.Writer) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
t := def.Clone()
|
||||
t.Proxy = nil
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
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()
|
||||
})
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
if t, ok := SharedTransport().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
return fallbackTransport()
|
||||
}
|
||||
|
||||
@@ -28,65 +28,19 @@ func TestDetectProxyEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
func TestNewBaseTransport_Default(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
tr := NewBaseTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Error("expected proxy func to be set when LARK_CLI_NO_PROXY is not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
func TestNewBaseTransport_NoProxy(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "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")
|
||||
tr := NewBaseTransport()
|
||||
if tr.Proxy != nil {
|
||||
t.Error("expected proxy func to be nil when LARK_CLI_NO_PROXY=1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,3 +156,35 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// 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
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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
84
package-lock.json
generated
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"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.17",
|
||||
"version": "1.0.11",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -27,11 +27,7 @@
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"scripts/install.js",
|
||||
"scripts/install-wizard.js",
|
||||
"scripts/run.js",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/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 { execFileSync } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const VERSION = require("../package.json").version;
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
|
||||
@@ -43,16 +43,13 @@ 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
|
||||
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
||||
args.push(url);
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
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"] }
|
||||
);
|
||||
}
|
||||
|
||||
function install() {
|
||||
@@ -67,12 +64,12 @@ function install() {
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
} else {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
@@ -88,16 +85,6 @@ 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,32 +41,21 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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, args, { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
Scopes: []string{"base:app:copy"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,8 +14,7 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
Scopes: []string{"base:app:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -137,8 +137,6 @@ 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,7 +6,6 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -20,16 +19,12 @@ 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: userOpenID,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -53,37 +48,18 @@ 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()
|
||||
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
|
||||
}
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
||||
stderr.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
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",
|
||||
@@ -92,35 +68,11 @@ 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)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), baseCreateHint) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
||||
}
|
||||
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)
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,14 +97,6 @@ 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",
|
||||
@@ -161,247 +105,14 @@ 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)
|
||||
}
|
||||
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, "")
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
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)
|
||||
if !strings.Contains(stderr.String(), baseCreateHint) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
||||
}
|
||||
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 (可管理权限)") {
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
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) {
|
||||
@@ -1230,9 +941,7 @@ 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, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
@@ -1383,8 +1092,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const baseCreateHint = "Tip: New bases include a default empty table with 5-10 blank records. After finishing table/field setup on this base, ask whether to delete that default table. If yes, run +table-list first, then delete the default table."
|
||||
|
||||
func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token").
|
||||
@@ -20,59 +17,6 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
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 {
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
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 {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint)
|
||||
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
|
||||
@@ -86,10 +30,13 @@ func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return body
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
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
|
||||
@@ -97,20 +44,54 @@ func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return body
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(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 executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
return ""
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-upload-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
|
||||
@@ -112,56 +112,6 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunRecordShareBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch").
|
||||
Body(map[string]interface{}{"record_ids": recordIDs}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
const maxShareBatchSize = 100
|
||||
|
||||
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
if len(recordIDs) == 0 {
|
||||
return common.FlagErrorf("--record-ids is required and must not be empty")
|
||||
}
|
||||
if len(recordIDs) > maxShareBatchSize {
|
||||
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deduplicateRecordIDs(runtime *common.RuntimeContext) []string {
|
||||
raw := runtime.StrSlice("record-ids")
|
||||
seen := make(map[string]bool, len(raw))
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, id := range raw {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executeRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
body := map[string]interface{}{
|
||||
"record_ids": recordIDs,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "share_links", "batch"),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordShareLinkCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-share-link-create",
|
||||
Description: "Generate share links for one or more records (max 100 per request)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
|
||||
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordShareBatch(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordShareBatch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordShareBatch(runtime)
|
||||
},
|
||||
}
|
||||
@@ -4,15 +4,11 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -109,8 +105,6 @@ 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,
|
||||
},
|
||||
},
|
||||
@@ -249,14 +243,10 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
@@ -282,78 +272,7 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordUpsert,
|
||||
BaseRecordBatchCreate,
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
|
||||
@@ -54,7 +54,6 @@ 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,7 +194,6 @@ 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
|
||||
}
|
||||
@@ -222,13 +221,11 @@ 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,7 +102,6 @@ var CalendarFreebusy = common.Shortcut{
|
||||
"user_id": userId,
|
||||
"need_rsvp_status": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -375,238 +375,6 @@ func TestCreate_NoEventIdReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CreateEvent_InvalidParamsWithDetail(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "end_time should be later than start_time"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Time",
|
||||
"--start", "2025-03-21T10:00:00+08:00",
|
||||
"--end", "2025-03-21T11:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "end_time should be later than start_time") {
|
||||
t.Errorf("expected detail value in message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CreateEvent_InvalidParamsWithoutDetailValue(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Time",
|
||||
"--start", "2025-03-21T10:00:00+08:00",
|
||||
"--end", "2025-03-21T11:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CreateEvent_InvalidParams_ErrorNotMap(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
RawBody: []byte(`{"code":190014,"msg":"invalid params","error":"just a string"}`),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Time",
|
||||
"--start", "2025-03-21T10:00:00+08:00",
|
||||
"--end", "2025-03-21T11:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CreateEvent_InvalidParams_NoDetailsKey(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"other_key": "no details here",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Time",
|
||||
"--start", "2025-03-21T10:00:00+08:00",
|
||||
"--end", "2025-03-21T11:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CreateEvent_InvalidParams_DetailItemNotMap(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{nil},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Time",
|
||||
"--start", "2025-03-21T10:00:00+08:00",
|
||||
"--end", "2025-03-21T11:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_190014",
|
||||
"summary": "Bad Attendees",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_190014/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "invalid attendee open_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/events/evt_190014",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bad Attendees",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_invalid",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid attendees with 190014, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid attendee open_id") {
|
||||
t.Errorf("expected detail value in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarAgenda tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -877,67 +645,6 @@ func TestAgenda_ExplicitCalendarId(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_InvalidParamsWithDetail(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/events/instance_view",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "start_time is required"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarAgenda, []string{
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_NonExitError_Passthrough(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/events/instance_view",
|
||||
RawBody: []byte("this is not json"),
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarAgenda, []string{
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-JSON response, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code != 0 {
|
||||
t.Fatalf("expected non-API error passthrough, got API error code %d", exitErr.Detail.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarFreebusy tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1018,46 +725,6 @@ func TestFreebusy_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreebusy_InvalidParamsWithDetail(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "user_id is invalid"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarFreebusy, []string{
|
||||
"+freebusy",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--user-id", "ou_someone",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 190014, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
|
||||
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "user_id is invalid") {
|
||||
t.Errorf("expected detail value in message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarSuggestion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
errCodeInvalidParamsWithDetail = 190014
|
||||
)
|
||||
|
||||
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
|
||||
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
|
||||
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
|
||||
// Returns an empty string if the structure doesn't match or the array is empty.
|
||||
func getErrorDetailValue(e *output.ErrDetail) string {
|
||||
if e == nil || e.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
errMap, ok := e.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
details, ok := errMap["details"].([]interface{})
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
detailObj, ok := details[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
val, _ := detailObj["value"].(string)
|
||||
return val
|
||||
}
|
||||
|
||||
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
|
||||
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
|
||||
// If the error is nil or doesn't match predefined codes, returns the original error.
|
||||
func wrapPredefinedError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
|
||||
if val := getErrorDetailValue(exitErr.Detail); val != "" {
|
||||
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
|
||||
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -37,10 +37,6 @@ type DriveMediaUploadAllConfig struct {
|
||||
ParentType string
|
||||
ParentNode *string
|
||||
Extra string
|
||||
// Reader, when non-nil, is used as the upload source instead of opening
|
||||
// FilePath. Callers must set FileName and FileSize explicitly. The reader
|
||||
// is NOT closed by UploadDriveMediaAll; the caller owns its lifetime.
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
type DriveMediaMultipartUploadConfig struct {
|
||||
@@ -53,17 +49,11 @@ type DriveMediaMultipartUploadConfig struct {
|
||||
}
|
||||
|
||||
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
var fileReader io.Reader
|
||||
if cfg.Reader != nil {
|
||||
fileReader = cfg.Reader
|
||||
} else {
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return "", WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
fileReader = f
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return "", WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", cfg.FileName)
|
||||
@@ -75,7 +65,7 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
|
||||
if cfg.Extra != "" {
|
||||
fd.AddField("extra", cfg.Extra)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
|
||||
@@ -181,12 +181,6 @@ func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
return v
|
||||
}
|
||||
|
||||
// StrSlice returns a string-slice flag value (supports CSV splitting and repeated flags).
|
||||
func (ctx *RuntimeContext) StrSlice(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
// CallAPI uses an internal HTTP wrapper with limited control over request/response.
|
||||
@@ -269,8 +263,8 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
// regardless of the current --as flag. Use this for APIs that must always be called
|
||||
// with TAT even when the surrounding shortcut runs as user.
|
||||
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
|
||||
// that must be called with TAT even when the surrounding shortcut runs as user.
|
||||
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
@@ -577,16 +571,12 @@ func enhancePermissionError(err error, requiredScopes []string) error {
|
||||
|
||||
// Mount registers the shortcut on a parent command.
|
||||
func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
s.MountWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
if s.Execute != nil {
|
||||
s.mountDeclarative(ctx, parent, f)
|
||||
s.mountDeclarative(parent, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
shortcut := s
|
||||
if len(shortcut.AuthTypes) == 0 {
|
||||
shortcut.AuthTypes = []string{"user"}
|
||||
@@ -602,7 +592,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
registerShortcutFlags(cmd, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
@@ -833,11 +823,7 @@ func rejectPositionalArgs() cobra.PositionalArgs {
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
if len(fl.Enum) > 0 {
|
||||
@@ -863,8 +849,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
cmd.Flags().StringSlice(fl.Name, nil, desc)
|
||||
default:
|
||||
cmd.Flags().String(fl.Name, fl.Default, desc)
|
||||
}
|
||||
@@ -876,7 +860,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
if len(fl.Enum) > 0 {
|
||||
vals := fl.Enum
|
||||
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
@@ -890,9 +874,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if s.HasFormat {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestShortcutMount_FlagCompletionsRegistered exercises the two
|
||||
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
|
||||
// the per-flag enum completion (runner.go:879) and the auto-injected --format
|
||||
// completion (runner.go:895).
|
||||
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(false)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
|
||||
// Enum flag completion.
|
||||
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --sort-by")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
|
||||
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
|
||||
}
|
||||
|
||||
// HasFormat-injected --format completion.
|
||||
fn, ok = cmd.GetFlagCompletionFunc("format")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --format")
|
||||
}
|
||||
got, _ = fn(cmd, nil, "")
|
||||
want := []string{"json", "pretty", "table", "ndjson", "csv"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("format completion = %v, want %v", got, want)
|
||||
}
|
||||
for i, v := range want {
|
||||
if got[i] != v {
|
||||
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
|
||||
// prevents the two registrations from landing in cobra's global map.
|
||||
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(true)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
|
||||
t.Fatal("did not expect completion func for --sort-by when disabled")
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user