mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c442fa27d1 | ||
|
|
35a8288baf | ||
|
|
79379fbc6f | ||
|
|
d0ab8ee7dc | ||
|
|
1608f95632 | ||
|
|
e10bf8eca2 | ||
|
|
c1d6042552 | ||
|
|
656c16a47f | ||
|
|
9dfaff4664 |
@@ -6,3 +6,6 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
Normal file
116
.github/workflows/arch-audit.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Architecture Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Monday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Dead code detection
|
||||
run: |
|
||||
echo "## Dead Code" >> report.md
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
|
||||
count=$(wc -l < deadcode.txt | tr -d ' ')
|
||||
echo "Found **$count** unreachable functions" >> report.md
|
||||
echo '```' >> report.md
|
||||
cat deadcode.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Package complexity
|
||||
run: |
|
||||
echo "## Package Complexity" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "| Package | Files | Lines | Deps |" >> report.md
|
||||
echo "|---------|-------|-------|------|" >> report.md
|
||||
found=0
|
||||
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
|
||||
dir=$(go list -f '{{.Dir}}' "$pkg")
|
||||
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
|
||||
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
|
||||
deps=$(go list -f '{{len .Imports}}' "$pkg")
|
||||
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
|
||||
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "| _(none)_ | | | |" >> report.md
|
||||
fi
|
||||
|
||||
- name: Dependency freshness
|
||||
run: |
|
||||
echo "## Outdated Dependencies" >> report.md
|
||||
echo '```' >> report.md
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Shortcut domains without E2E tests:" >> report.md
|
||||
echo "" >> report.md
|
||||
found=0
|
||||
for domain in $(ls -d shortcuts/*/); do
|
||||
name=$(basename "$domain")
|
||||
if [ "$name" = "common" ]; then continue; fi
|
||||
if [ ! -d "tests/cli_e2e/$name" ]; then
|
||||
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "All shortcut domains have E2E test directories." >> report.md
|
||||
fi
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
echo "## Coverage" >> report.md
|
||||
packages=$(go list ./... | grep -v 'tests/cli_e2e')
|
||||
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
|
||||
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
|
||||
echo "Current total coverage: **${total:-n/a}**" >> report.md
|
||||
|
||||
- name: Publish report
|
||||
run: |
|
||||
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cat report.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: arch-audit-${{ github.run_number }}
|
||||
path: report.md
|
||||
retention-days: 90
|
||||
333
.github/workflows/ci.yml
vendored
Normal file
333
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,333 @@
|
||||
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 ./... 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 ./... 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 }}
|
||||
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
83
.github/workflows/cli-e2e.yml
vendored
@@ -1,83 +0,0 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
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
58
.github/workflows/coverage.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
28
.github/workflows/gitleaks.yml
vendored
28
.github/workflows/gitleaks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
26
.github/workflows/license-header.yml
vendored
26
.github/workflows/license-header.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
60
.github/workflows/lint.yml
vendored
60
.github/workflows/lint.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
43
.github/workflows/tests.yml
vendored
43
.github/workflows/tests.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
@@ -70,6 +70,14 @@ linters:
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
@@ -100,6 +108,16 @@ linters:
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
# ── output: shortcuts must use ctx.Out() ──
|
||||
- pattern: fmt\.Print(f|ln)?\b
|
||||
msg: >-
|
||||
use ctx.Out() or ctx.OutFormat() for structured JSON output.
|
||||
fmt.Print* bypasses the output envelope and breaks --jq/--format.
|
||||
# ── logging: shortcuts must return errors, not log.Fatal ──
|
||||
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
|
||||
msg: >-
|
||||
use structured error return, not log.Fatal/Panic.
|
||||
Shortcuts must return errors to the framework for proper exit code handling.
|
||||
# ── filepath: functions that access the filesystem ──
|
||||
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
|
||||
msg: >-
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -18,9 +18,11 @@ make test # Full: vet + unit + integration
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
2. `go vet ./...`
|
||||
3. `gofmt -l .` — must produce no output
|
||||
4. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
@@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
### E2E Testing
|
||||
|
||||
**Dry-run E2E (required for every shortcut change)**
|
||||
- Validates request structure without calling real APIs
|
||||
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
|
||||
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
|
||||
- No secrets needed — runs on fork PRs
|
||||
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
|
||||
|
||||
**Live E2E (required for new flows or behavior changes)**
|
||||
- Validates real API round-trips
|
||||
- Place in `tests/cli_e2e/<domain>/`
|
||||
- Must be self-contained: create -> use -> cleanup
|
||||
- Needs bot credentials (CI secrets, skipped on fork PRs)
|
||||
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
|
||||
|
||||
| Change | Dry-run E2E | Live E2E |
|
||||
|--------|:-----------:|:--------:|
|
||||
| New shortcut | Required | Required |
|
||||
| Modify shortcut flags/params | Required | If behavior changes |
|
||||
| Shortcut bug fix | Required | If regression risk |
|
||||
| Internal refactor (no shortcut impact) | Not needed | Not needed |
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -359,6 +382,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -101,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
|
||||
}
|
||||
|
||||
53
internal/util/strings_test.go
Normal file
53
internal/util/strings_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate", "hello world", 5, "hello"},
|
||||
{"empty", "", 5, ""},
|
||||
{"zero limit", "hello", 0, ""},
|
||||
{"CJK characters", "你好世界测试", 4, "你好世界"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStr(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStrWithEllipsis(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"truncate with ellipsis", "hello world", 8, "hello..."},
|
||||
{"limit less than 3", "hello", 2, "he"},
|
||||
{"limit equals 3", "hello world", 3, "..."},
|
||||
{"empty", "", 5, ""},
|
||||
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
|
||||
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -263,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 bot-only APIs (e.g. image/file upload)
|
||||
// that must be called with TAT even when the surrounding shortcut runs as user.
|
||||
// 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.
|
||||
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -28,6 +28,8 @@ var DocsFetch = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
@@ -46,6 +48,8 @@ var DocsFetch = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
|
||||
120
shortcuts/drive/drive_create_folder.go
Normal file
120
shortcuts/drive/drive_create_folder.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveCreateFolderSpec struct {
|
||||
Name string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateFolderSpec(runtime *common.RuntimeContext) driveCreateFolderSpec {
|
||||
return driveCreateFolderSpec{
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
func (s driveCreateFolderSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"folder_token": s.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateFolder creates a new Drive folder under the specified parent
|
||||
// folder, or under the caller's root folder when --folder-token is omitted.
|
||||
var DriveCreateFolder = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-folder",
|
||||
Description: "Create a folder in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:folder:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "folder name", Required: true},
|
||||
{Name: "folder-token", Desc: "parent folder token (default: root folder)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit --folder-token to create the folder in the caller's root folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateFolderSpec(runtime)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Create a folder in Drive").
|
||||
POST("/open-apis/drive/v1/files/create_folder").
|
||||
Desc("[1] Create folder").
|
||||
Body(spec.RequestBody())
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateFolderSpec(runtime)
|
||||
|
||||
target := "root folder"
|
||||
if spec.FolderToken != "" {
|
||||
target = "folder " + common.MaskToken(spec.FolderToken)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folderToken := common.GetString(data, "token")
|
||||
if folderToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"name": spec.Name,
|
||||
"folder_token": folderToken,
|
||||
"parent_folder_token": spec.FolderToken,
|
||||
}
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
|
||||
if spec.Name == "" {
|
||||
return output.ErrValidation("--name must not be empty")
|
||||
}
|
||||
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
|
||||
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
266
shortcuts/drive/drive_create_folder_test.go
Normal file
266
shortcuts/drive/drive_create_folder_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateDriveCreateFolderSpecRejectsInvalidInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateFolderSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
spec: driveCreateFolderSpec{},
|
||||
wantErr: "--name must not be empty",
|
||||
},
|
||||
{
|
||||
name: "name too long",
|
||||
spec: driveCreateFolderSpec{
|
||||
Name: strings.Repeat("a", 257),
|
||||
},
|
||||
wantErr: "maximum of 256 bytes",
|
||||
},
|
||||
{
|
||||
name: "invalid folder token",
|
||||
spec: driveCreateFolderSpec{
|
||||
Name: "Reports",
|
||||
FolderToken: "../bad",
|
||||
},
|
||||
wantErr: "--folder-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateFolderSpec(tt.spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-folder"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("name", " Weekly Reports "); err != nil {
|
||||
t.Fatalf("set --name: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " fld_parent "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveCreateFolder.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/drive/v1/files/create_folder" {
|
||||
t.Fatalf("unexpected dry-run API call: %#v", got.API[0])
|
||||
}
|
||||
if got.API[0].Body["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", got.API[0].Body["name"], "Weekly Reports")
|
||||
}
|
||||
if got.API[0].Body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_created",
|
||||
"url": "https://example.feishu.cn/drive/folder/fld_created",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/fld_created/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", " Weekly Reports ",
|
||||
"--folder-token", " fld_parent ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", body["name"], "Weekly Reports")
|
||||
}
|
||||
if body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", body["folder_token"], "fld_parent")
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["folder_token"] != "fld_created" {
|
||||
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_created")
|
||||
}
|
||||
if data["parent_folder_token"] != "fld_parent" {
|
||||
t.Fatalf("parent_folder_token = %#v, want %q", data["parent_folder_token"], "fld_parent")
|
||||
}
|
||||
if data["name"] != "Weekly Reports" {
|
||||
t.Fatalf("name = %#v, want %q", data["name"], "Weekly Reports")
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/drive/folder/fld_created" {
|
||||
t.Fatalf("url = %#v, want folder url", data["url"])
|
||||
}
|
||||
|
||||
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_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
permBody := decodeCapturedJSONBody(t, permStub)
|
||||
if permBody["member_type"] != "openid" || permBody["member_id"] != "ou_current_user" || permBody["perm"] != "full_access" || permBody["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", permBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_root_child",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Inbox",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["folder_token"] != "" {
|
||||
t.Fatalf("folder_token = %#v, want empty string for root create", body["folder_token"])
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["folder_token"] != "fld_root_child" {
|
||||
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_root_child")
|
||||
}
|
||||
if data["parent_folder_token"] != "" {
|
||||
t.Fatalf("parent_folder_token = %#v, want empty string", data["parent_folder_token"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://example.feishu.cn/drive/folder/unknown",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Broken Folder",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "returned no folder token") {
|
||||
t.Fatalf("err = %v, want missing folder token error", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on error, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateFolder,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-folder",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
|
||||
@@ -602,3 +602,51 @@ func TestMediaBufferReader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaBufferFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
label string
|
||||
buf mediaBuffer
|
||||
want string
|
||||
}{
|
||||
{"original URL filename", mediaBuffer{name: "report.pdf", ext: ".pdf"}, "report.pdf"},
|
||||
{"name with spaces", mediaBuffer{name: "Q1 report.pdf", ext: ".pdf"}, "Q1 report.pdf"},
|
||||
{"download fallback", mediaBuffer{name: "download", ext: ""}, "download"},
|
||||
{"ext not leaked into name", mediaBuffer{name: "x", ext: ".mp4"}, "x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.label, func(t *testing.T) {
|
||||
if got := tt.buf.FileName(); got != tt.want {
|
||||
t.Fatalf("FileName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMediaBufferFromBytesURLFilename locks in the URL -> mediaBuffer.name
|
||||
// wiring so a future refactor cannot regress back to the "media.<ext>" synthetic
|
||||
// filename that was shipped in 91067ec.
|
||||
func TestNewMediaBufferFromBytesURLFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
label string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"path filename", "http://example.com/report.pdf", "report.pdf"},
|
||||
{"filename survives query string", "http://example.com/videos/clip.mp4?token=abc", "clip.mp4"},
|
||||
{"percent-encoded spaces decoded", "http://example.com/Q1%20report.pdf", "Q1 report.pdf"},
|
||||
{"no path falls back to download", "http://example.com/", "download"},
|
||||
{"non-http scheme falls back to download", "ftp://example.com/x.pdf", "download"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.label, func(t *testing.T) {
|
||||
mb := newMediaBufferFromBytes([]byte("payload"), ".pdf", tt.url)
|
||||
if got := mb.FileName(); got != tt.want {
|
||||
t.Fatalf("FileName() for %q = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
if got := mb.FileName(); strings.HasPrefix(got, "media") && tt.want != "media" {
|
||||
t.Fatalf("regression: FileName() returned synthetic %q for %q", got, tt.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,6 +584,7 @@ func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType strin
|
||||
type mediaBuffer struct {
|
||||
data []byte
|
||||
ext string // file extension including leading dot, e.g. ".mp4"
|
||||
name string // original file name extracted from the source URL
|
||||
}
|
||||
|
||||
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
|
||||
@@ -598,7 +599,14 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
return &mediaBuffer{data: data, ext: ext}, nil
|
||||
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
||||
}
|
||||
|
||||
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
|
||||
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
|
||||
// without going through the hardened download transport.
|
||||
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
|
||||
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
|
||||
}
|
||||
|
||||
// Reader returns a new io.Reader over the buffered data. Each call returns a
|
||||
@@ -608,9 +616,9 @@ func (b *mediaBuffer) Reader() io.Reader {
|
||||
return bytes.NewReader(b.data)
|
||||
}
|
||||
|
||||
// FileName returns a synthetic file name based on the URL extension.
|
||||
// FileName returns the original file name extracted from the source URL.
|
||||
func (b *mediaBuffer) FileName() string {
|
||||
return "media" + b.ext
|
||||
return b.name
|
||||
}
|
||||
|
||||
// FileType returns the IM file type detected from the extension.
|
||||
@@ -1131,7 +1139,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", f)
|
||||
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -1172,7 +1180,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
@@ -1200,7 +1208,7 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", r)
|
||||
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -1232,7 +1240,7 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
}
|
||||
fd.AddFile("file", r)
|
||||
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
|
||||
@@ -892,3 +892,38 @@ func TestResolveLocalMediaFile(t *testing.T) {
|
||||
t.Fatalf("resolveLocalMedia(file) = %q, want %q", got, "file_via_resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadFileToIMPreservesLocalFileName locks in that local uploads keep
|
||||
// the basename of the caller-supplied path as the multipart file_name, so the
|
||||
// URL-side fix for mediaBuffer cannot silently regress the local branch later.
|
||||
func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
|
||||
var gotBody string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/open-apis/im/v1/files") {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotBody = string(body)
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_key": "file_uploaded"},
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
localName := "Q1-meeting-notes.pdf"
|
||||
if err := os.WriteFile(localName, []byte("pdfdata"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
||||
t.Fatalf("upload body missing local filename %q; got: %q", localName, gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type WbCliOutput struct {
|
||||
}
|
||||
|
||||
type WbCliOutputData struct {
|
||||
To string `json:"to"`
|
||||
Result interface{} `json:"result"`
|
||||
To string `json:"to"`
|
||||
Result struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -31,9 +30,8 @@ var formatCodeMap = map[string]int{
|
||||
FormatMermaid: 2,
|
||||
}
|
||||
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"}
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
|
||||
var wbUpdateAuthTypes = []string{"user", "bot"}
|
||||
var skipDeleteNodesBatchSleep = false // for accelerate UT testing only
|
||||
var wbUpdateFlags = []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
@@ -82,19 +80,6 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to update content."
|
||||
var delNum int
|
||||
var err error
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf(" %d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
|
||||
switch format {
|
||||
@@ -103,7 +88,11 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(nodes).Desc("create all nodes of the whiteboard.")
|
||||
reqBody := rawNodesCreateReq{
|
||||
Nodes: nodes,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
@@ -111,16 +100,11 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
|
||||
}
|
||||
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
@@ -185,31 +169,17 @@ type createResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type deleteResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type simpleNodeResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
Id string `json:"id"`
|
||||
Children []string `json:"children"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type deleteNodeReqBody struct {
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type plantumlCreateReq struct {
|
||||
PlantUmlCode string `json:"plant_uml_code"`
|
||||
SyntaxType int `json:"syntax_type"`
|
||||
DiagramType int `json:"diagram_type,omitempty"`
|
||||
ParseMode int `json:"parse_mode,omitempty"`
|
||||
Overwrite bool `json:"overwrite,omitempty"`
|
||||
}
|
||||
|
||||
type rawNodesCreateReq struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
Overwrite bool `json:"overwrite,omitempty"`
|
||||
}
|
||||
|
||||
type plantumlCreateResp struct {
|
||||
@@ -220,7 +190,7 @@ type plantumlCreateResp struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool) {
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
|
||||
@@ -229,121 +199,17 @@ func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
if wbOutput.RawNodes != nil {
|
||||
wbNodes = struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
}{
|
||||
Nodes: wbOutput.RawNodes,
|
||||
}
|
||||
wbNodes = wbOutput.RawNodes
|
||||
isRaw = true
|
||||
} else {
|
||||
wbNodes = wbOutput.Data.Result
|
||||
if wbOutput.Data.Result.Nodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
wbNodes = wbOutput.Data.Result.Nodes
|
||||
}
|
||||
return wbNodes, nil, isRaw
|
||||
}
|
||||
|
||||
func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext, wbToken string, newNodeIDs []string, dryRun bool) (int, []string, error) {
|
||||
resp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var nodes simpleNodeResp
|
||||
err = json.Unmarshal(resp.RawBody, &nodes)
|
||||
if err != nil {
|
||||
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if nodes.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
|
||||
// 收集所有新节点及其 children 的 ID,递归处理
|
||||
protectedIDs := make(map[string]bool)
|
||||
for _, id := range newNodeIDs {
|
||||
protectedIDs[id] = true
|
||||
}
|
||||
// 构建 node map 以便快速查找
|
||||
nodeMap := make(map[string][]string)
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeMap[node.Id] = node.Children
|
||||
}
|
||||
}
|
||||
// 递归收集所有 children
|
||||
visited := make(map[string]bool)
|
||||
var collectChildren func(id string)
|
||||
collectChildren = func(id string) {
|
||||
if visited[id] {
|
||||
return
|
||||
}
|
||||
visited[id] = true
|
||||
if children, ok := nodeMap[id]; ok {
|
||||
for _, child := range children {
|
||||
protectedIDs[child] = true
|
||||
collectChildren(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range newNodeIDs {
|
||||
collectChildren(id)
|
||||
}
|
||||
|
||||
// 确定要删除的节点
|
||||
nodeIds := make([]string, 0, len(nodes.Data.Nodes))
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeIds = append(nodeIds, node.Id)
|
||||
}
|
||||
}
|
||||
delIds := make([]string, 0, len(nodeIds))
|
||||
for _, nodeId := range nodeIds {
|
||||
if !protectedIDs[nodeId] {
|
||||
delIds = append(delIds, nodeId)
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
// 实际删除节点,按每批最多100个进行切分
|
||||
for i := 0; i < len(delIds); i += 100 {
|
||||
if !skipDeleteNodesBatchSleep {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
}
|
||||
end := i + 100
|
||||
if end > len(delIds) {
|
||||
end = len(delIds)
|
||||
}
|
||||
batchIds := delIds[i:end]
|
||||
delReq := deleteNodeReqBody{
|
||||
Ids: batchIds,
|
||||
}
|
||||
resp, err = runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodDelete,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(wbToken)),
|
||||
Body: delReq,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, output.ErrNetwork(fmt.Sprintf("delete whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var delResp deleteResponse
|
||||
err = json.Unmarshal(resp.RawBody, &delResp)
|
||||
if err != nil {
|
||||
return 0, nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard delete response failed: %v", err))
|
||||
}
|
||||
if delResp.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(delResp.Code, "delete whiteboard nodes failed", fmt.Sprintf("delete whiteboard nodes failed: %s", delResp.Msg))
|
||||
}
|
||||
}
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
|
||||
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
|
||||
syntaxType := formatCodeMap[format]
|
||||
@@ -352,6 +218,7 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0, // 0 表示自动识别
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
@@ -383,20 +250,7 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
|
||||
|
||||
outData := make(map[string]string)
|
||||
outData["created_node_id"] = createResp.Data.NodeID
|
||||
newNodeIDs := []string{createResp.Data.NodeID}
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, newNodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_id"] != "" {
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
}
|
||||
@@ -413,11 +267,15 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
|
||||
return err
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
reqBody := rawNodesCreateReq{
|
||||
Nodes: nodes,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
Body: nodes,
|
||||
Body: reqBody,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
@@ -452,19 +310,7 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
|
||||
}
|
||||
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
|
||||
@@ -478,36 +478,9 @@ invalid
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{},
|
||||
},
|
||||
{
|
||||
"id": "old-node-2",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
// Mock: Create nodes API response with overwrite in request body
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/plantuml",
|
||||
@@ -520,16 +493,6 @@ func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-overwrite", "--input_format", "mermaid", "--overwrite", "--source", source}
|
||||
@@ -539,36 +502,9 @@ A-->B`
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{"old-child-1"},
|
||||
},
|
||||
{
|
||||
"id": "old-child-1",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
// Mock: Create nodes API response with overwrite in request body
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
@@ -581,16 +517,6 @@ func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-overwrite", "--input_format", "raw", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
|
||||
@@ -58,7 +58,11 @@ var WikiNodeCreate = common.Shortcut{
|
||||
return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As())
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiNodeCreateSpec(runtime)
|
||||
@@ -70,7 +74,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy)
|
||||
runtime.Out(wikiNodeCreateOutput(execution), nil)
|
||||
runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -293,6 +297,9 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
|
||||
}
|
||||
|
||||
return &wikiNodeCreateExecution{
|
||||
Node: node,
|
||||
@@ -462,3 +469,15 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa
|
||||
"has_child": node.HasChild,
|
||||
}
|
||||
}
|
||||
|
||||
func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} {
|
||||
if execution == nil || execution.Node == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
out := wikiNodeCreateOutput(execution)
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type fakeWikiNodeCreateClient struct {
|
||||
spaces map[string]*wikiSpaceRecord
|
||||
nodes map[string]*wikiNodeRecord
|
||||
createNode *wikiNodeRecord
|
||||
returnNilNode bool
|
||||
createErr error
|
||||
getSpaceErr error
|
||||
getNodeErr error
|
||||
@@ -65,6 +66,9 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st
|
||||
if fake.createErr != nil {
|
||||
return nil, fake.createErr
|
||||
}
|
||||
if fake.returnNilNode {
|
||||
return nil, nil
|
||||
}
|
||||
if fake.createNode != nil {
|
||||
return fake.createNode, nil
|
||||
}
|
||||
@@ -81,6 +85,15 @@ func wikiTestConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func wikiPermissionTestConfig(userOpenID string) *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: fmt.Sprintf("wiki-permission-test-app-%d", wikiTestConfigSeq.Add(1)),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
}
|
||||
|
||||
func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "wiki"}
|
||||
@@ -268,6 +281,26 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
wikiMyLibrarySpaceID: {SpaceID: "space_my_library", SpaceType: "my_library"},
|
||||
},
|
||||
returnNilNode: true,
|
||||
}
|
||||
|
||||
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, wikiNodeCreateSpec{
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
ObjType: "docx",
|
||||
Title: "Roadmap",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") {
|
||||
t.Fatalf("expected missing node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateDryRunShowsMyLibraryLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -484,3 +517,126 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want completed creation message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_created",
|
||||
"obj_token": "docx_created",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/wik_created/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Node",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
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_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal permission body: %v", err)
|
||||
}
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
if body["perm_type"] != "container" {
|
||||
t.Fatalf("perm_type = %#v, want %q", body["perm_type"], "container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_created",
|
||||
"obj_token": "docx_created",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Node",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got)
|
||||
}
|
||||
|
||||
if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
|
||||
@@ -103,7 +103,7 @@ metadata:
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`;仅当用户提供明确搜索关键词时使用 `+record-search`;取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-shortcut-record-value.md`](references/lark-base-shortcut-record-value.md) | 写前先 `+field-list`;只写存储字段;批量单次建议不超过 `500` 条;附件不要走这里 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-shortcut-record-value.md`](references/lark-base-shortcut-record-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-delete / +record-history-list` | 删除记录,或查询某条记录的变更历史 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md)、[`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 删除时用户已明确目标可直接执行并带 `--yes`;历史查询按 `table-id + record-id`,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
@@ -287,8 +287,8 @@ metadata:
|
||||
|
||||
- `+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用,只能串行执行。
|
||||
- `+record-list` 分页时,`--limit` 最大 `200`;先拉首批并检查 `has_more`,只有用户明确需要更多数据时再继续翻页。
|
||||
- 批量写入时,单批建议不超过 `500` 条。
|
||||
- 连续写入同一表时,建议串行写入,批次间延迟 `0.5–1` 秒。
|
||||
- 批量写入时,单批不超过 `200` 条。
|
||||
- 连续写入同一表时,必须串行写入,批次间延迟 `0.5–1` 秒。
|
||||
|
||||
### 4.4 确认与回复规则
|
||||
|
||||
@@ -311,51 +311,5 @@ metadata:
|
||||
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
|
||||
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| 系统字段 / 公式字段写入失败 | 只读字段被当成可写字段 | 改为写存储字段,计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| `1254104` | 批量超 500 条 | 分批调用 |
|
||||
| `1254104` | 批量超 200 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
|
||||
## 6. 参考文档
|
||||
|
||||
- [lark-base-shortcut-field-properties.md](references/lark-base-shortcut-field-properties.md) — `+field-create/+field-update` 调用前必看,各类型 field JSON 规范
|
||||
- [role-config.md](references/role-config.md) — 角色权限配置详解
|
||||
- [lark-base-shortcut-record-value.md](references/lark-base-shortcut-record-value.md) — record 写入(`+record-upsert / +record-batch-create / +record-batch-update`)调用前必看,各类型 record JSON 规范
|
||||
- [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) — `+record-batch-create` 用法与 `--json` 结构
|
||||
- [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) — `+record-batch-update` 用法与 `--json` 结构
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) — formula 字段写法、函数约束、CurrentValue 规则、跨表计算模式
|
||||
- [lookup-field-guide.md](references/lookup-field-guide.md) — lookup 字段配置规则、where/aggregate 约束、与 formula 的取舍
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) — 视图筛选配置
|
||||
- [lark-base-record-list.md](references/lark-base-record-list.md) — 记录列表读取与分页
|
||||
- [lark-base-record-search.md](references/lark-base-record-search.md) — 关键词搜索记录
|
||||
- [lark-base-advperm-enable.md](references/lark-base-advperm-enable.md) — `+advperm-enable` 启用高级权限
|
||||
- [lark-base-advperm-disable.md](references/lark-base-advperm-disable.md) — `+advperm-disable` 停用高级权限
|
||||
- [lark-base-role-list.md](references/lark-base-role-list.md) — `+role-list` 列出角色
|
||||
- [lark-base-role-get.md](references/lark-base-role-get.md) — `+role-get` 获取角色详情
|
||||
- [lark-base-role-create.md](references/lark-base-role-create.md) — `+role-create` 创建角色
|
||||
- [lark-base-role-update.md](references/lark-base-role-update.md) — `+role-update` 更新角色
|
||||
- [lark-base-role-delete.md](references/lark-base-role-delete.md) — `+role-delete` 删除角色
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) — dashboard 模块工作流指引
|
||||
- [dashboard-block-data-config.md](references/dashboard-block-data-config.md) — Block `data_config` 结构、图表类型、filter 规则
|
||||
- [lark-base-workflow.md](references/lark-base-workflow.md) — workflow 命令索引
|
||||
- [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) — `+workflow-create/+workflow-update` JSON body 结构详解
|
||||
- [lark-base-data-query.md](references/lark-base-data-query.md) — `+data-query` 聚合分析,含 DSL 结构、支持字段类型、聚合函数
|
||||
- [examples.md](references/examples.md) — 完整操作示例
|
||||
|
||||
## 7. 命令分组
|
||||
|
||||
> **执行前必做:** 从下表定位到命令后,务必先阅读对应命令的 reference 文档,再调用命令。
|
||||
|
||||
| 命令分组 | 说明 |
|
||||
|----------|------|
|
||||
| [`table commands`](references/lark-base-table.md) | `+table-list / +table-get / +table-create / +table-update / +table-delete` |
|
||||
| [`field commands`](references/lark-base-field.md) | `+field-list / +field-get / +field-create / +field-update / +field-delete / +field-search-options` |
|
||||
| [`record commands`](references/lark-base-record.md) | `+record-search / +record-list / +record-get / +record-upsert / +record-batch-create / +record-batch-update / +record-upload-attachment / +record-delete` |
|
||||
| [`view commands`](references/lark-base-view.md) | `+view-list / +view-get / +view-create / +view-delete / +view-get-* / +view-set-* / +view-rename` |
|
||||
| [`data-query commands`](references/lark-base-data-query.md) | `+data-query` |
|
||||
| [`history commands`](references/lark-base-history.md) | `+record-history-list` |
|
||||
| [`base / workspace commands`](references/lark-base-workspace.md) | `+base-create / +base-get / +base-copy` |
|
||||
| [`advperm commands`](references/lark-base-advperm-enable.md) | `+advperm-enable / +advperm-disable` |
|
||||
| [`role commands`](references/lark-base-role-list.md) | `+role-list / +role-get / +role-create / +role-update / +role-delete` |
|
||||
| [`form commands`](references/lark-base-form-create.md) | `+form-list / +form-get / +form-create / +form-update / +form-delete` |
|
||||
| [`form questions commands`](references/lark-base-form-questions-create.md) | `+form-questions-list / +form-questions-create / +form-questions-update / +form-questions-delete` |
|
||||
| [`workflow commands`](references/lark-base-workflow.md) | `+workflow-list / +workflow-get / +workflow-create / +workflow-update / +workflow-enable / +workflow-disable` |
|
||||
| [`dashboard / dashboard-block commands`](references/lark-base-dashboard.md) | `+dashboard-list / +dashboard-get / +dashboard-create / +dashboard-update / +dashboard-delete / +dashboard-arrange / +dashboard-block-list / +dashboard-block-get / +dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` |
|
||||
|
||||
@@ -86,6 +86,21 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
|
||||
| `bitable` | `lark-base` | 多维表格 |
|
||||
| 其他 | 告知用户暂不支持 | — |
|
||||
|
||||
## 重要:任务卡片(task 标签)
|
||||
|
||||
`docs +fetch` 默认不会查询/展开文档中内嵌的任务详情(例如任务标题、状态、负责人等)。
|
||||
它会在返回的 Markdown 中保留任务引用,并返回任务 ID(GUID),例如:
|
||||
|
||||
```html
|
||||
<task task-id="30597dc9-262e-4597-97f4-f8efcd1aeb95"></task>
|
||||
```
|
||||
|
||||
如果用户需要查看该任务的详情,需要用返回的 `task-id` 再调用任务 CLI 查询:
|
||||
|
||||
```bash
|
||||
lark-cli task tasks get --as user --params '{"task_guid":"30597dc9-262e-4597-97f4-f8efcd1aeb95"}'
|
||||
```
|
||||
|
||||
## 工具组合
|
||||
|
||||
| 需求 | 工具 |
|
||||
|
||||
@@ -19,6 +19,7 @@ metadata:
|
||||
- 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
@@ -196,6 +197,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
|
||||
73
skills/lark-drive/references/lark-drive-create-folder.md
Normal file
73
skills/lark-drive/references/lark-drive-create-folder.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# drive +create-folder(创建云空间文件夹)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在飞书云空间中创建一个新文件夹。该 shortcut 对原生 `drive files create_folder` 做了一层更适合日常使用的封装:`--folder-token` 可省略,此时会在调用者根目录创建;如果使用 `--as bot`,创建成功后 CLI 会尝试把新文件夹的可管理权限自动授予当前 CLI 用户。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 在根目录创建文件夹
|
||||
lark-cli drive +create-folder \
|
||||
--name "周报归档"
|
||||
|
||||
# 在指定父文件夹下创建子文件夹
|
||||
lark-cli drive +create-folder \
|
||||
--folder-token <PARENT_FOLDER_TOKEN> \
|
||||
--name "2026-W16"
|
||||
|
||||
# 预览底层调用
|
||||
lark-cli drive +create-folder \
|
||||
--folder-token <PARENT_FOLDER_TOKEN> \
|
||||
--name "分析资料" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
成功后会返回一个 JSON 对象,常见字段包括:
|
||||
|
||||
- `folder_token`:新建文件夹 token,可直接用于后续 `drive +move`、`drive +upload` 等命令
|
||||
- `url`:新建文件夹链接(如果接口返回)
|
||||
- `name`:文件夹名称
|
||||
- `parent_folder_token`:父文件夹 token;为空字符串表示创建在根目录
|
||||
- `permission_grant`(可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果文件夹是**以应用身份(bot)创建**的,如 `lark-cli drive +create-folder --as bot`,在创建成功后 CLI 会**尝试为当前 CLI 用户自动授予该文件夹的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该文件夹的可管理权限
|
||||
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限
|
||||
> - `status = failed`:文件夹已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文件夹
|
||||
>
|
||||
> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name` | 是 | 文件夹名称,不能为空,最长 256 字节 |
|
||||
| `--folder-token` | 否 | 父文件夹 token;省略时表示在调用者根目录创建 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **根目录创建**:不传 `--folder-token` 时,shortcut 会向 API 显式传空字符串 `folder_token=""`,让后端按“根目录”语义创建
|
||||
- **bot 自动授权**:只有在 `--as bot` 时,结果才会额外带上 `permission_grant`
|
||||
- **原生 API 仍可用**:如果用户明确要求按底层 API 字段调用,仍可继续使用 `lark-cli drive files create_folder`
|
||||
|
||||
## 推荐场景
|
||||
|
||||
- 用户说“在云空间新建一个文件夹 / 目录”时,优先使用 `drive +create-folder`
|
||||
- 用户给了父文件夹链接或 token,需要在其下继续分层建目录时,传 `--folder-token`
|
||||
- 如果后续还要上传文件、移动文件、建子目录,优先复用返回值里的 `folder_token`
|
||||
|
||||
> [!CAUTION]
|
||||
> `drive +create-folder` 是**写入操作**,执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -211,7 +211,7 @@ The reply appears in the target message's thread and does not show up in the mai
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
|
||||
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
|
||||
@@ -211,7 +211,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
|
||||
- `--content` must be valid JSON
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
|
||||
@@ -21,6 +21,7 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。
|
||||
|
||||
@@ -45,6 +45,31 @@ lark-cli wiki +node-create \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
成功后会返回一个 JSON 对象,常见字段包括:
|
||||
|
||||
- `resolved_space_id`:最终用于创建的真实知识空间 ID
|
||||
- `resolved_by`:空间解析来源,可能是 `explicit_space_id`、`parent_node_token`、`my_library`
|
||||
- `node_token`:新建知识库节点 token
|
||||
- `obj_token`:节点关联对象 token
|
||||
- `obj_type`:节点关联对象类型
|
||||
- `node_type`:节点类型
|
||||
- `title`:节点标题
|
||||
- `permission_grant`(可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果节点是**以应用身份(bot)创建**的,如 `lark-cli wiki +node-create --as bot`,在创建成功后 CLI 会**尝试为当前 CLI 用户自动授予该知识库节点的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该知识库节点的可管理权限
|
||||
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限
|
||||
> - `status = failed`:节点已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该节点
|
||||
>
|
||||
> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
@@ -84,6 +109,7 @@ lark-cli wiki +node-create \
|
||||
- 仅传 `--title`:会展示 `my_library` 解析 + 创建节点 两步调用
|
||||
- 仅传 `--parent-node-token`:会展示“查询父节点 -> 创建节点”两步调用
|
||||
- 同时需要 `my_library` 和父节点时:会展示三步调用链
|
||||
- **bot 自动授权**:若使用 `--as bot`,结果还会额外带上 `permission_grant`,用于说明是否已自动为当前 CLI 用户授予新建节点的可管理权限
|
||||
- **输出结果**:成功后会返回 `resolved_space_id`、`resolved_by`、`node_token`、`obj_token`、`obj_type`、`node_type`、`title` 等字段,便于后续继续操作
|
||||
|
||||
## 推荐场景
|
||||
|
||||
Reference in New Issue
Block a user