mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
73 Commits
v1.0.9
...
sun/doubao
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ea222ba7 | ||
|
|
d8e08736f1 | ||
|
|
138a2ef785 | ||
|
|
0cb6cdf818 | ||
|
|
5d9b3d305f | ||
|
|
9229c50fcf | ||
|
|
d25f79bb64 | ||
|
|
4d84994ce6 | ||
|
|
6b56e0fdde | ||
|
|
1262aac480 | ||
|
|
abb02cd46c | ||
|
|
db7d3cb64d | ||
|
|
5134719da9 | ||
|
|
5a0e1d3dd9 | ||
|
|
09e60eeaf4 | ||
|
|
4f90fd3b77 | ||
|
|
6212513c43 | ||
|
|
e8df0ea63e | ||
|
|
6d0d687be2 | ||
|
|
148a04a7f8 | ||
|
|
ba19bd9f93 | ||
|
|
830fb3bbe5 | ||
|
|
1ad7cfab5b | ||
|
|
5280517d4b | ||
|
|
3ad6f2fac4 | ||
|
|
be79485fe3 | ||
|
|
94bba91224 | ||
|
|
0d50616e77 | ||
|
|
d5784eac28 | ||
|
|
663c24aadf | ||
|
|
6ad25cd452 | ||
|
|
c442fa27d1 | ||
|
|
35a8288baf | ||
|
|
79379fbc6f | ||
|
|
d0ab8ee7dc | ||
|
|
1608f95632 | ||
|
|
e10bf8eca2 | ||
|
|
c1d6042552 | ||
|
|
656c16a47f | ||
|
|
9dfaff4664 | ||
|
|
f0e724cbd4 | ||
|
|
03ba542a60 | ||
|
|
5fa68ccaa0 | ||
|
|
1583af7fc0 | ||
|
|
44e7b5b477 | ||
|
|
66ec27f6e1 | ||
|
|
162c25527b | ||
|
|
0c7a930fc3 | ||
|
|
ec9e67c21a | ||
|
|
74e4a97f52 | ||
|
|
fe4123436f | ||
|
|
052e2112bf | ||
|
|
76a834e928 | ||
|
|
20761fa56a | ||
|
|
2a301246f9 | ||
|
|
abc374f1a3 | ||
|
|
2910cde73a | ||
|
|
7fdc162ff7 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb |
@@ -6,3 +6,6 @@ coverage:
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
|
||||
github_checks:
|
||||
annotations: true
|
||||
|
||||
116
.github/workflows/arch-audit.yml
vendored
Normal file
116
.github/workflows/arch-audit.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Architecture Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Monday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Dead code detection
|
||||
run: |
|
||||
echo "## Dead Code" >> report.md
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
|
||||
count=$(wc -l < deadcode.txt | tr -d ' ')
|
||||
echo "Found **$count** unreachable functions" >> report.md
|
||||
echo '```' >> report.md
|
||||
cat deadcode.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Package complexity
|
||||
run: |
|
||||
echo "## Package Complexity" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "| Package | Files | Lines | Deps |" >> report.md
|
||||
echo "|---------|-------|-------|------|" >> report.md
|
||||
found=0
|
||||
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
|
||||
dir=$(go list -f '{{.Dir}}' "$pkg")
|
||||
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
|
||||
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
|
||||
deps=$(go list -f '{{len .Imports}}' "$pkg")
|
||||
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
|
||||
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "| _(none)_ | | | |" >> report.md
|
||||
fi
|
||||
|
||||
- name: Dependency freshness
|
||||
run: |
|
||||
echo "## Outdated Dependencies" >> report.md
|
||||
echo '```' >> report.md
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
echo "" >> report.md
|
||||
echo "Shortcut domains without E2E tests:" >> report.md
|
||||
echo "" >> report.md
|
||||
found=0
|
||||
for domain in $(ls -d shortcuts/*/); do
|
||||
name=$(basename "$domain")
|
||||
if [ "$name" = "common" ]; then continue; fi
|
||||
if [ ! -d "tests/cli_e2e/$name" ]; then
|
||||
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "All shortcut domains have E2E test directories." >> report.md
|
||||
fi
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
echo "## Coverage" >> report.md
|
||||
packages=$(go list ./... | grep -v 'tests/cli_e2e')
|
||||
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
|
||||
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
|
||||
echo "Current total coverage: **${total:-n/a}**" >> report.md
|
||||
|
||||
- name: Publish report
|
||||
run: |
|
||||
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
cat report.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: arch-audit-${{ github.run_number }}
|
||||
path: report.md
|
||||
retention-days: 90
|
||||
334
.github/workflows/ci.yml
vendored
Normal file
334
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
fast-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "$unformatted"
|
||||
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
|
||||
exit 1
|
||||
fi
|
||||
- name: Check go.mod tidiness
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
|
||||
exit 1
|
||||
fi
|
||||
- name: Coverage summary
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [ ! -f coverage.txt ]; then
|
||||
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Dead code check (incremental)
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
git worktree remove -f /tmp/dc-base 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
git worktree remove -f /tmp/dc-base
|
||||
|
||||
# Only new dead code blocks the PR
|
||||
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
|
||||
if [ -s /tmp/dc-new.txt ]; then
|
||||
echo "::group::New dead code"
|
||||
cat /tmp/dc-new.txt
|
||||
echo "::endgroup::"
|
||||
echo "::error::New dead code detected — remove unreachable functions before merging"
|
||||
exit 1
|
||||
fi
|
||||
echo "No new dead code introduced"
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Gitleaks
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
- name: govulncheck
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
license-header:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
run: |
|
||||
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo ""
|
||||
echo "::error::One or more CI jobs failed — see table above"
|
||||
exit 1
|
||||
fi
|
||||
135
.github/workflows/cli-e2e.yml
vendored
135
.github/workflows/cli-e2e.yml
vendored
@@ -1,135 +0,0 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
58
.github/workflows/coverage.yml
vendored
58
.github/workflows/coverage.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
28
.github/workflows/gitleaks.yml
vendored
28
.github/workflows/gitleaks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
26
.github/workflows/license-header.yml
vendored
26
.github/workflows/license-header.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
60
.github/workflows/lint.yml
vendored
60
.github/workflows/lint.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
43
.github/workflows/tests.yml
vendored
43
.github/workflows/tests.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,5 +33,7 @@ tests/mail/reports/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -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 |
|
||||
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -2,6 +2,107 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
|
||||
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
|
||||
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
|
||||
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
|
||||
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
|
||||
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
|
||||
|
||||
### CI
|
||||
|
||||
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
|
||||
|
||||
## [v1.0.12] - 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
|
||||
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
|
||||
- **mail**: Return recall hints for sent emails when recall is available (#481)
|
||||
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
|
||||
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support im oapi range download for large files (#283)
|
||||
- **sheets**: Add filter view and condition shortcuts (#422)
|
||||
- **wiki**: Add wiki move shortcut with async task polling (#436)
|
||||
- **drive**: Add drive `+create-shortcut` shortcut (#432)
|
||||
- **drive**: Add drive files patch metadata API (#444)
|
||||
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Support large base attachment uploads (#441)
|
||||
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
|
||||
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
|
||||
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
|
||||
- **mail**: Restrict `--output-dir` to current working directory (#376)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
|
||||
- **task**: Document sections API resources, permissions, and URL parsing (#430)
|
||||
- **doc**: Clarify when markdown escaping is needed (#312)
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
@@ -303,6 +404,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
|
||||
13
README.md
13
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -30,13 +30,15 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -149,6 +151,7 @@ lark-cli auth status
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
@@ -198,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
|
||||
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
11
README.zh.md
11
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -36,7 +36,9 @@
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -150,6 +152,7 @@ lark-cli auth status
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
@@ -199,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
|
||||
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
@@ -96,10 +96,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -184,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
|
||||
|
||||
// Phase 2: confirmation
|
||||
var confirmed bool
|
||||
form2 := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(msg.ConfirmAuth).
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form2.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
|
||||
return &interactiveResult{
|
||||
Domains: selectedDomains,
|
||||
ScopeLevel: permLevel,
|
||||
|
||||
@@ -24,6 +24,7 @@ type loginMsg struct {
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
@@ -58,9 +59,10 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
@@ -93,9 +95,10 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
|
||||
@@ -69,6 +69,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
@@ -190,11 +190,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,8 +376,8 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
@@ -392,15 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -469,7 +469,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
@@ -619,8 +619,8 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
@@ -634,6 +634,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +746,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,7 +774,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
|
||||
115
cmd/build.go
Normal file
115
cmd/build.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// BuildOption configures optional aspects of the command tree construction.
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
|
||||
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
|
||||
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.keychain = kc
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is the internal constructor that also returns Factory for error handling.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
cfg := &buildConfig{
|
||||
streams: cmdutil.SystemIO(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
|
||||
rootCmd.SetContext(ctx)
|
||||
rootCmd.SetIn(cfg.streams.In)
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
}
|
||||
@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
|
||||
// Show QR code in terminal
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
// Branch on TTY: human-friendly copy in interactive terminals,
|
||||
// preserve original copy for AI / non-interactive callers.
|
||||
if f.IOStreams.IsTerminal {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
} else {
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
|
||||
// Step 3: Poll for result
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
|
||||
@@ -10,45 +10,56 @@ import (
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
ScanOrOpenLink string
|
||||
WaitingForScan string
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
SelectAction string
|
||||
CreateNewApp string
|
||||
ConfigExistingApp string
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
WaitingForScan string // active polling indicator
|
||||
// Non-TTY (AI / non-interactive) variants — preserve original copy
|
||||
OpenLinkNonTTY string // primary link prompt
|
||||
WaitingForScanNonTTY string // passive waiting indicator
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScan: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
SelectAction: "选择操作",
|
||||
CreateNewApp: "一键配置应用 (推荐) ",
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
WaitingForScan: "正在获取你的应用配置结果...",
|
||||
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScanNonTTY: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScan: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
SelectAction: "Select action",
|
||||
CreateNewApp: "Set up your app with one click (Recommended)",
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
WaitingForScan: "Fetching configuration results...",
|
||||
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScanNonTTY: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
|
||||
@@ -48,17 +48,20 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
|
||||
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
t.Helper()
|
||||
fields := map[string]string{
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"SelectAction": msg.SelectAction,
|
||||
"CreateNewApp": msg.CreateNewApp,
|
||||
"ConfigExistingApp": msg.ConfigExistingApp,
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
|
||||
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
|
||||
18
cmd/init.go
Normal file
18
cmd/init.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
// SetDefaultFS replaces the global filesystem implementation used by internal
|
||||
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
|
||||
// the default OS filesystem is restored.
|
||||
//
|
||||
// Call this before Build or Execute to take effect.
|
||||
func SetDefaultFS(fs vfs.FS) {
|
||||
if fs == nil {
|
||||
fs = vfs.OsFs{}
|
||||
}
|
||||
vfs.DefaultFS = fs
|
||||
}
|
||||
49
cmd/root.go
49
cmd/root.go
@@ -14,15 +14,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -30,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -95,38 +85,9 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
f, rootCmd := buildInternal(context.Background(), inv)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -190,6 +151,12 @@ func isCompletionCommand(args []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
|
||||
@@ -135,7 +135,7 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
|
||||
@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDisabled bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, true},
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// SchemaOptions holds all inputs for the schema command.
|
||||
type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
@@ -41,7 +44,7 @@ func printServices(w io.Writer) {
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
@@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -359,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -369,7 +377,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -451,6 +459,7 @@ func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]s
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
@@ -469,9 +478,9 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec)
|
||||
printResourceList(out, spec, mode)
|
||||
} else {
|
||||
output.PrintJson(out, spec)
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -492,6 +501,7 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -500,13 +510,26 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
@@ -525,3 +548,67 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -177,11 +177,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
37
internal/cmdutil/completion.go
Normal file
37
internal/cmdutil/completion.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Cobra keeps completion callbacks in a package-global map keyed by
|
||||
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
|
||||
// outlive the command itself. Skip registration when the current invocation
|
||||
// will not serve a completion request.
|
||||
var flagCompletionsDisabled atomic.Bool
|
||||
|
||||
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
|
||||
// registering and no-op. Typically set once at process start.
|
||||
func SetFlagCompletionsDisabled(disabled bool) {
|
||||
flagCompletionsDisabled.Store(disabled)
|
||||
}
|
||||
|
||||
// FlagCompletionsDisabled reports the current switch state.
|
||||
func FlagCompletionsDisabled() bool {
|
||||
return flagCompletionsDisabled.Load()
|
||||
}
|
||||
|
||||
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
|
||||
// and honors the package switch. The underlying error is swallowed to match
|
||||
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
|
||||
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
|
||||
if flagCompletionsDisabled.Load() {
|
||||
return
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
|
||||
}
|
||||
78
internal/cmdutil/completion_test.go
Normal file
78
internal/cmdutil/completion_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected default false")
|
||||
}
|
||||
SetFlagCompletionsDisabled(true)
|
||||
if !FlagCompletionsDisabled() {
|
||||
t.Fatal("expected true after Set(true)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(false)
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected false after Set(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// When disabled, a *cobra.Command must be collectable after the caller drops
|
||||
// its reference — i.e. the wrapper did not touch cobra's global map.
|
||||
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
const N = 5
|
||||
var collected atomic.Int32
|
||||
func() {
|
||||
for range N {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
|
||||
}
|
||||
}()
|
||||
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
|
||||
for range 30 {
|
||||
runtime.GC()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := collected.Load(); int(got) != N {
|
||||
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When enabled, the registered completion must be reachable via cobra.
|
||||
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(false)
|
||||
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
want := []cobra.Completion{"a", "b"}
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return want, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
fn, ok := cmd.GetFlagCompletionFunc("foo")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func to be registered")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected completion result: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -34,27 +32,26 @@ import (
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
if streams == nil {
|
||||
streams = SystemIO()
|
||||
}
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
IOStreams: streams,
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: f.Keychain,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
@@ -93,9 +90,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
@@ -122,7 +119,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -142,7 +139,7 @@ func buildSDKTransport() http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain keychain.KeychainAccess
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
@@ -164,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -189,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -217,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
fd.AddField(k, formatFormFieldValue(v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
||||
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
||||
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
||||
// backends reject when parsing the field as an integer. Use decimal notation
|
||||
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
||||
// All other types fall through to %v.
|
||||
func formatFormFieldValue(v any) string {
|
||||
if n, ok := v.(float64); ok {
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
|
||||
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
|
||||
// float64 delegates to %g which switches to scientific notation at ~1e6
|
||||
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
|
||||
// integer reject that, surfacing as a generic "params error".
|
||||
func TestFormatFormFieldValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
|
||||
{"float64 below scientific threshold", float64(358934), "358934"},
|
||||
{"float64 zero", float64(0), "0"},
|
||||
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
|
||||
{"float64 negative", float64(-42), "-42"},
|
||||
{"float64 fractional preserved", float64(3.14), "3.14"},
|
||||
{"string pass-through", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"int via %v", 42, "42"},
|
||||
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatFormFieldValue(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "io"
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IOStreams provides the standard input/output/error streams.
|
||||
// Commands should use these instead of os.Stdin/Stdout/Stderr
|
||||
@@ -14,3 +19,13 @@ type IOStreams struct {
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
func SystemIO() *IOStreams {
|
||||
return &IOStreams{
|
||||
In: os.Stdin, //nolint:forbidigo // entry point for real stdio
|
||||
Out: os.Stdout, //nolint:forbidigo // entry point for real stdio
|
||||
ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ import (
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain keychain.KeychainAccess
|
||||
keychain func() keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
if kc == nil {
|
||||
kc = keychain.Default
|
||||
}
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type noopKC struct{}
|
||||
@@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,29 @@ import (
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"chats", "messages", "tasks", "created_tasks",
|
||||
}
|
||||
|
||||
// asGenericSlice converts any slice value into []interface{}.
|
||||
// Returns the slice and true when v is a slice, regardless of element type
|
||||
// ([]interface{}, []map[string]interface{}, []MyStruct, etc.). This keeps
|
||||
// formatter logic working when business code uses typed slices.
|
||||
func asGenericSlice(v interface{}) ([]interface{}, bool) {
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
if s, ok := v.([]interface{}); ok {
|
||||
return s, true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]interface{}, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
out[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
@@ -23,7 +47,7 @@ var knownArrayFields = []string{
|
||||
func FindArrayField(data map[string]interface{}) string {
|
||||
for _, name := range knownArrayFields {
|
||||
if arr, ok := data[name]; ok {
|
||||
if _, isArr := arr.([]interface{}); isArr {
|
||||
if _, isArr := asGenericSlice(arr); isArr {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -31,7 +55,7 @@ func FindArrayField(data map[string]interface{}) string {
|
||||
// Fallback: lexicographically first array field (deterministic)
|
||||
var candidates []string
|
||||
for k, v := range data {
|
||||
if _, isArr := v.([]interface{}); isArr {
|
||||
if _, isArr := asGenericSlice(v); isArr {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
}
|
||||
@@ -68,11 +92,12 @@ func toGeneric(v interface{}) interface{} {
|
||||
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
|
||||
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
|
||||
//
|
||||
// If data is already a plain []interface{}, it is returned as-is.
|
||||
// If data is already a slice, it is returned as a []interface{}. Typed slices
|
||||
// such as []map[string]interface{} are also accepted via asGenericSlice.
|
||||
func ExtractItems(data interface{}) []interface{} {
|
||||
resultMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
if arr, ok := asGenericSlice(data); ok {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
@@ -81,7 +106,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
||||
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
if field := FindArrayField(dataObj); field != "" {
|
||||
if items, ok := dataObj[field].([]interface{}); ok {
|
||||
if items, ok := asGenericSlice(dataObj[field]); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -90,7 +115,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 2: direct map — result[arrayField]
|
||||
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
||||
if field := FindArrayField(resultMap); field != "" {
|
||||
if items, ok := resultMap[field].([]interface{}); ok {
|
||||
if items, ok := asGenericSlice(resultMap[field]); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,113 @@ func TestExtractItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: shortcuts often collect results into typed slices like
|
||||
// []map[string]interface{} instead of []interface{}. ExtractItems must
|
||||
// recognise those so --format table/csv/ndjson render the array rather
|
||||
// than falling back to a key/value view of the envelope.
|
||||
func TestExtractItems_TypedSlice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data interface{}
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "direct map with []map[string]interface{} under known field",
|
||||
data: map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_a", "name": "Alice"},
|
||||
{"chat_id": "oc_b", "name": "Bob"},
|
||||
},
|
||||
"has_more": true,
|
||||
"total": float64(2),
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "envelope with []map[string]interface{} under data.messages",
|
||||
data: map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{"message_id": "om_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "direct map with []map[string]interface{} under created_tasks",
|
||||
data: map[string]interface{}{
|
||||
"created_tasks": []map[string]interface{}{
|
||||
{"task_id": "t1"},
|
||||
{"task_id": "t2"},
|
||||
{"task_id": "t3"},
|
||||
},
|
||||
},
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "typed slice of structs via fallback",
|
||||
data: map[string]interface{}{
|
||||
"widgets": []struct {
|
||||
Name string `json:"name"`
|
||||
}{{Name: "x"}, {Name: "y"}},
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "raw typed slice passed directly",
|
||||
data: []map[string]interface{}{
|
||||
{"k": "v"},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
items := ExtractItems(tc.data)
|
||||
if len(items) != tc.want {
|
||||
t.Fatalf("expected %d items, got %d (%v)", tc.want, len(items), items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --format table on the 7 affected shortcuts used to print
|
||||
// the envelope as a key/value table because the typed slice was ignored.
|
||||
// After the fix, the array should be expanded into a proper header row.
|
||||
func TestFormatValue_Table_TypedSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Lark test"},
|
||||
},
|
||||
"has_more": true,
|
||||
"total": float64(1),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatTable)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "chat_id") {
|
||||
t.Errorf("table output should expose chat_id column, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "oc_abc") {
|
||||
t.Errorf("table output should contain the chat row, got:\n%s", out)
|
||||
}
|
||||
// The fallback bug manifested as the envelope being rendered as rows:
|
||||
// the 'has_more' / 'total' envelope keys would appear as first-column
|
||||
// labels. A correct render puts the array's element keys in the header
|
||||
// and keeps envelope metadata out of the table body.
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "has_more") || strings.HasPrefix(trimmed, "total ") {
|
||||
t.Errorf("envelope field leaked into table body:\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_LegacyFormats(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -33,6 +33,14 @@ const (
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -60,6 +68,20 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
71
internal/output/lark_errors_test.go
Normal file
71
internal/output/lark_errors_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
|
||||
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantExitCode int
|
||||
wantType string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: LarkErrDriveResourceContention,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
},
|
||||
{
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: LarkErrDriveCrossBrand,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
{
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
|
||||
if gotExitCode != tt.wantExitCode {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
|
||||
}
|
||||
if gotHint == "" {
|
||||
t.Fatal("expected non-empty hint")
|
||||
}
|
||||
if !strings.Contains(gotHint, tt.wantHint) {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +146,20 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
// RunSkillsUpdate installs skills, trying the .well-known source first and
|
||||
// falling back to the GitHub repo on failure or timeout.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
}
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -159,7 +168,7 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
package-lock.json
generated
Normal file
84
package-lock.json
generated
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
|
||||
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.2.0",
|
||||
"fast-string-width": "^1.1.0",
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
|
||||
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
|
||||
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
|
||||
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.14",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -27,7 +27,11 @@
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"scripts/install.js",
|
||||
"scripts/install-wizard.js",
|
||||
"scripts/run.js",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
70
rebase-420/dd05477.md
Normal file
70
rebase-420/dd05477.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Cherry-pick 冲突解决报告: `dd05477`
|
||||
|
||||
- **原始 commit**: `dd05477` feat: add SetDefaultFS to allow replacing the global filesystem implementation
|
||||
- **作者**: tuxedomm, 2026-04-09
|
||||
- **新 commit**: `4d84994`
|
||||
- **目标分支**: `feat/main_rebased_420`(基于 `larksuite/cli` 最新 main)
|
||||
|
||||
## 改动范围
|
||||
|
||||
10 个文件, +179 / -70:
|
||||
|
||||
- **新增**: `cmd/build.go`, `cmd/init.go`
|
||||
- **修改**: `cmd/root.go`, `cmd/root_integration_test.go`, `internal/cmdutil/factory_default.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`, `internal/credential/integration_test.go`
|
||||
|
||||
核心意图:
|
||||
- 把 `cmd.Execute()` 里的 root 命令组装逻辑抽取到新文件 `cmd/build.go` 的 `buildInternal()`, 并暴露 `Build()` 作为库入口
|
||||
- 引入 `cmd/init.go` 里的 `SetDefaultFS(fs vfs.FS)` 允许调用方在 `Build/Execute` 之前替换全局 fs
|
||||
- `cmdutil.NewDefault(inv)` 签名调整为 `NewDefault(streams *IOStreams, inv InvocationContext)`
|
||||
- `credentialDeps.Keychain` 从 `keychain.KeychainAccess` 改为 `func() keychain.KeychainAccess`(惰性读取, 允许构造后替换)
|
||||
- `cmdutil.SystemIO()` 新函数封装对真实 stdio 的引用
|
||||
|
||||
## 冲突情况
|
||||
|
||||
只有一个文件冲突: `cmd/root.go`(2 处)
|
||||
|
||||
| 位置 | HEAD(main) | fork(dd05477) |
|
||||
|---|---|---|
|
||||
| imports 段 | 保留 `cmd/api`, `cmd/auth`, `cmd/completion`, `cmdconfig`, `cmd/doctor`, `cmd/profile`, `cmd/schema`, `cmd/service`, `cmdupdate`, `shortcuts` 等 | 全部删除(这些 import 随 Execute 函数体一起搬去新文件 `cmd/build.go`)|
|
||||
| `Execute()` 函数体 | 完整包含 Factory 构造 + rootCmd 构造 + 子命令注册 + strict-mode 剪枝 | 精简为 `f, rootCmd := buildInternal(context.Background(), inv)` |
|
||||
|
||||
### 为什么会冲突
|
||||
|
||||
fork 的 dd05477 比 fork 之前落后 main 很多 commit, 而 main 上(比如 PR #391)在 fork 不知道的情况下加了 `rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))` 这一行 —— 它处于 fork 想整体搬走的那段代码里。git 无法自动判断这一行应该保留还是跟着搬, 所以报冲突。
|
||||
|
||||
## 解决方案
|
||||
|
||||
**两处冲突都采用 fork 的重构结构**(把 imports / 组装逻辑搬去 `cmd/build.go`), 但在 `cmd/build.go` 的 `buildInternal()` 里**追加**了 main 新增的 update 命令。
|
||||
|
||||
### 具体改动
|
||||
|
||||
在 `cmd/build.go` 里:
|
||||
|
||||
```go
|
||||
// imports 段补上
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
|
||||
// 在 rootCmd.AddCommand(completion.NewCmdCompletion(f)) 之后追加
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
```
|
||||
|
||||
如果不这样做, 就会丢失 main PR #391 引入的 `lark-cli update` 子命令。
|
||||
|
||||
## 非冲突文件处理
|
||||
|
||||
其余 9 个文件的 patch 全部直接应用, 无语义冲突:
|
||||
|
||||
- `cmd/build.go`, `cmd/init.go`: 新增文件
|
||||
- `cmd/root_integration_test.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/credential/integration_test.go`: 跟随签名变更调整调用方(`NewDefault(nil, ...)`、`cachedHttpClientFunc(&Factory{...})` 等)
|
||||
- `internal/cmdutil/factory_default.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`: 签名/结构体字段类型调整
|
||||
- `cmd/root.go`: 冲突段外其余部分(update 检查、错误处理等)保持原样
|
||||
|
||||
## 验证
|
||||
|
||||
- `go build ./...` 通过
|
||||
- `go test ./cmd/... ./internal/cmdutil/... ./internal/credential/...` 全部通过
|
||||
|
||||
## 依赖
|
||||
|
||||
- `internal/vfs` 包(`DefaultFS`、`OsFs`、`FS` interface)在 main 上已存在, `SetDefaultFS` 要切换的全局状态有完整基础
|
||||
- `cmdupdate` 包(main PR #391)已存在
|
||||
372
scripts/install-wizard.js
Normal file
372
scripts/install-wizard.js
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const messages = {
|
||||
zh: {
|
||||
setup: "正在设置 Feishu/Lark CLI...",
|
||||
step1: "正在安装 %s...",
|
||||
step1Upgrade: "正在升级 %s (v%s → v%s)...",
|
||||
step1Skip: "已安装 (v%s),跳过",
|
||||
step1Done: "已全局安装",
|
||||
step1Upgraded: "已升级到 v%s",
|
||||
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
|
||||
step2: "安装 AI Skills",
|
||||
step2Skip: "已安装,跳过",
|
||||
step2Spinner: "正在安装 Skills...",
|
||||
step2Done: "Skills 已安装",
|
||||
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
|
||||
step3: "正在配置应用...",
|
||||
step3NotFound: "未找到 lark-cli,终止",
|
||||
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
|
||||
step3Skip: "跳过应用配置",
|
||||
step3Done: "应用已配置",
|
||||
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
||||
step4: "授权",
|
||||
step4NotFound: "未找到 lark-cli,跳过授权",
|
||||
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
|
||||
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
||||
step4Done: "授权完成",
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n现在可以对你的 AI 工具(Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
},
|
||||
en: {
|
||||
setup: "Setting up Feishu/Lark CLI...",
|
||||
step1: "Installing %s globally...",
|
||||
step1Upgrade: "Upgrading %s (v%s → v%s)...",
|
||||
step1Skip: "Already installed (v%s). Skipped",
|
||||
step1Done: "Installed globally",
|
||||
step1Upgraded: "Upgraded to v%s",
|
||||
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
|
||||
step2: "Install AI skills",
|
||||
step2Skip: "Already installed. Skipped",
|
||||
step2Spinner: "Installing skills...",
|
||||
step2Done: "Skills installed",
|
||||
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
|
||||
step3: "Configuring app...",
|
||||
step3NotFound: "lark-cli not found. Aborting",
|
||||
step3Found: "Found existing app (App ID: %s). Use this app?",
|
||||
step3Skip: "Skipped app configuration",
|
||||
step3Done: "App configured",
|
||||
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
||||
step4: "Authorization",
|
||||
step4NotFound: "lark-cli not found. Skipping authorization",
|
||||
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
|
||||
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
||||
step4Done: "Authorization complete",
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
|
||||
cancelled: "Installation cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleCancel(value, msg) {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function execCmd(cmd, args, opts) {
|
||||
if (isWindows) {
|
||||
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
|
||||
}
|
||||
return execFileSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
execCmd(cmd, args, { stdio: "inherit", ...opts });
|
||||
}
|
||||
|
||||
function runSilent(cmd, args, opts = {}) {
|
||||
return execCmd(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function runSilentAsync(cmd, args, opts = {}) {
|
||||
const actualCmd = isWindows ? "cmd.exe" : cmd;
|
||||
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(actualCmd, actualArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...opts,
|
||||
}, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(template, ...values) {
|
||||
let i = 0;
|
||||
return template.replace(/%s/g, () => values[i++] ?? "");
|
||||
}
|
||||
|
||||
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
|
||||
function whichLarkCli() {
|
||||
try {
|
||||
const prefix = execFileSync("npm", ["prefix", "-g"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).toString().trim();
|
||||
const bin = isWindows
|
||||
? path.join(prefix, "lark-cli.cmd")
|
||||
: path.join(prefix, "bin", "lark-cli");
|
||||
if (fs.existsSync(bin)) return bin;
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
// Fallback to which/where if npm prefix lookup fails.
|
||||
try {
|
||||
const cmd = isWindows ? "where" : "which";
|
||||
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
|
||||
.toString()
|
||||
.split("\n")[0]
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
|
||||
function getLatestVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
|
||||
const ver = out.toString().trim();
|
||||
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare two semver strings. Returns true if a < b. */
|
||||
function semverLessThan(a, b) {
|
||||
const pa = a.replace(/-.*$/, "").split(".").map(Number);
|
||||
const pb = b.replace(/-.*$/, "").split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
|
||||
function getGloballyInstalledVersion() {
|
||||
try {
|
||||
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
|
||||
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
|
||||
return match ? match[1] : "unknown";
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether lark-cli config already exists. Returns app ID or null. */
|
||||
function getExistingAppId(binPath) {
|
||||
try {
|
||||
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
|
||||
const json = JSON.parse(out.toString());
|
||||
return json.appId || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse --lang from process.argv, returns "zh", "en", or null. */
|
||||
function parseLangArg() {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--lang" && args[i + 1]) {
|
||||
const val = args[i + 1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
if (args[i].startsWith("--lang=")) {
|
||||
const val = args[i].split("=")[1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stepSelectLang() {
|
||||
const fromArg = parseLangArg();
|
||||
if (fromArg) return fromArg;
|
||||
|
||||
const lang = await p.select({
|
||||
message: "请选择语言 / Select language",
|
||||
options: [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
],
|
||||
});
|
||||
return handleCancel(lang, messages.zh);
|
||||
}
|
||||
|
||||
async function stepInstallGlobally(msg) {
|
||||
const installedVer = getGloballyInstalledVersion();
|
||||
const latestVer = getLatestVersion();
|
||||
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
|
||||
|
||||
if (installedVer && !needsUpgrade) {
|
||||
p.log.info(fmt(msg.step1Skip, installedVer));
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
if (needsUpgrade) {
|
||||
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
|
||||
} else {
|
||||
s.start(fmt(msg.step1, PKG));
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
||||
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
||||
return needsUpgrade;
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step1Fail, PKG));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function skillsAlreadyInstalled() {
|
||||
try {
|
||||
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
return /^lark-/m.test(out.toString());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
s.stop(msg.step2Skip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
s.stop(msg.step2Done);
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepConfigInit(msg, lang) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step3);
|
||||
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
s.stop(msg.step3NotFound);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const appId = getExistingAppId(larkCli);
|
||||
s.stop(msg.step3);
|
||||
|
||||
if (appId) {
|
||||
const reuse = await p.confirm({
|
||||
message: fmt(msg.step3Found, appId),
|
||||
});
|
||||
if (handleCancel(reuse, msg) && reuse) {
|
||||
p.log.info(msg.step3Skip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
run(larkCli, ["config", "init", "--new", "--lang", lang]);
|
||||
p.log.success(msg.step3Done);
|
||||
} catch (_) {
|
||||
p.log.error(msg.step3Fail);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stepAuthLogin(msg) {
|
||||
const larkCli = whichLarkCli();
|
||||
if (!larkCli) {
|
||||
p.log.warn(msg.step4NotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
const yes = await p.confirm({
|
||||
message: msg.step4Confirm,
|
||||
});
|
||||
if (p.isCancel(yes)) {
|
||||
p.cancel(msg.cancelled);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!yes) {
|
||||
p.log.info(msg.step4Skip);
|
||||
return;
|
||||
}
|
||||
|
||||
p.log.step(msg.step4);
|
||||
try {
|
||||
run(larkCli, ["auth", "login"]);
|
||||
p.log.success(msg.step4Done);
|
||||
} catch (_) {
|
||||
p.log.warn(msg.step4Fail);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const lang = await stepSelectLang();
|
||||
const msg = messages[lang];
|
||||
|
||||
p.intro(msg.setup);
|
||||
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
|
||||
p.outro(msg.done);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const { execFileSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
const VERSION = require("../package.json").version;
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
|
||||
@@ -43,13 +43,16 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
const args = [
|
||||
"--fail", "--location", "--silent", "--show-error",
|
||||
"--connect-timeout", "10", "--max-time", "120",
|
||||
"--output", destPath,
|
||||
];
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
||||
args.push(url);
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
}
|
||||
|
||||
function install() {
|
||||
@@ -64,12 +67,12 @@ function install() {
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
@@ -85,6 +88,16 @@ function install() {
|
||||
}
|
||||
}
|
||||
|
||||
// When triggered as a postinstall hook under npx, skip the binary download.
|
||||
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
||||
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
||||
const isNpxPostinstall =
|
||||
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
||||
|
||||
if (isNpxPostinstall) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,21 +41,32 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(bin)) {
|
||||
console.error(
|
||||
`Error: lark-cli binary not found at ${bin}\n\n` +
|
||||
`This usually means the postinstall script was skipped.\n` +
|
||||
`Common causes:\n` +
|
||||
` - npm is configured with ignore-scripts=true\n` +
|
||||
` - The postinstall download failed\n\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Intercept "install" subcommand — run the setup wizard directly,
|
||||
// bypassing the native binary (which may not exist yet under npx).
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "install") {
|
||||
require("./install-wizard.js");
|
||||
} else {
|
||||
// Auto-download binary if missing (e.g. npx skipped postinstall).
|
||||
if (!fs.existsSync(bin)) {
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, LARK_CLI_RUN: "true" },
|
||||
});
|
||||
} catch (_) {
|
||||
console.error(
|
||||
`\nFailed to auto-install lark-cli binary.\n` +
|
||||
`To fix, run the install script manually:\n` +
|
||||
` node "${path.join(__dirname, "install.js")}"\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
try {
|
||||
execFileSync(bin, args, { stdio: "inherit" });
|
||||
} catch (e) {
|
||||
process.exit(e.status || 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:copy"},
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:create"},
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,12 +20,16 @@ import (
|
||||
)
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
||||
}
|
||||
|
||||
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) {
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
||||
}
|
||||
|
||||
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if authTypes != nil {
|
||||
shortcut.AuthTypes = authTypes
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
@@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
@@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
||||
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["copied"] != true {
|
||||
t.Fatalf("copied = %#v, want true", data["copied"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "base_token"); got != "app_new" {
|
||||
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
t.Run("create bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create user", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
@@ -151,6 +429,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err should not mention array: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Fatalf("stdout=%q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -259,7 +618,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
||||
@@ -277,7 +636,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
||||
@@ -865,6 +1224,157 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_big_1",
|
||||
"block_size": float64(8 * 1024 * 1024),
|
||||
"block_num": float64(3),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
t.Fatalf("prepare body=%s", prepareBody)
|
||||
}
|
||||
|
||||
firstPartBody := string(partStubs[0].CapturedBody)
|
||||
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
|
||||
!strings.Contains(firstPartBody, "upload_big_1") ||
|
||||
!strings.Contains(firstPartBody, `name="seq"`) ||
|
||||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
|
||||
!strings.Contains(firstPartBody, `name="size"`) ||
|
||||
!strings.Contains(firstPartBody, "8388608") {
|
||||
t.Fatalf("first part body=%s", firstPartBody)
|
||||
}
|
||||
|
||||
lastPartBody := string(partStubs[2].CapturedBody)
|
||||
if !strings.Contains(lastPartBody, `name="seq"`) ||
|
||||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
|
||||
!strings.Contains(lastPartBody, `name="size"`) ||
|
||||
!strings.Contains(lastPartBody, "4194305") {
|
||||
t.Fatalf("last part body=%s", lastPartBody)
|
||||
}
|
||||
|
||||
finishBody := string(finishStub.CapturedBody)
|
||||
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
|
||||
!strings.Contains(finishBody, `"block_num":3`) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -904,6 +1414,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
@@ -1021,7 +1562,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if runtime.Bool("without-content") {
|
||||
body["without_content"] = true
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(body)
|
||||
Body(buildBaseCreateBody(runtime))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
@@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "copied": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
@@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
@@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func extractBasePermissionToken(base map[string]interface{}) string {
|
||||
for _, key := range []string{"base_token", "app_token"} {
|
||||
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate == nil {
|
||||
t.Fatal("expected validate hook")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("create validate err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = common.ParseJSON([]byte(raw), &body)
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
return parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
}
|
||||
|
||||
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
|
||||
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
|
||||
@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
|
||||
@@ -5,15 +5,11 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -21,8 +17,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
@@ -37,7 +33,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
@@ -52,7 +48,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
@@ -61,15 +57,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id")).
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
Body(map[string]interface{}{
|
||||
@@ -102,7 +125,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
@@ -124,6 +147,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
@@ -151,6 +177,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
@@ -209,47 +243,30 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: &parentNode,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", baseAttachmentParentType)
|
||||
fd.AddField("parent_node", baseToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
|
||||
}
|
||||
|
||||
func validateViewCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseObjectList(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func validateViewJSONObject(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONValue(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func executeViewList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetGroup,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetSort,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
|
||||
@@ -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 {
|
||||
@@ -488,12 +488,46 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
|
||||
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
|
||||
// that should be preserved as-is in JSON output.
|
||||
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if ctx.JqExpr != "" {
|
||||
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
if ctx.outputErr == nil {
|
||||
ctx.outputErr = err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(ctx.IO().Out)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(env)
|
||||
}
|
||||
|
||||
// OutFormat prints output based on --format flag.
|
||||
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
||||
// When JqExpr is set, routes through Out() regardless of format.
|
||||
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.outFormat(data, meta, prettyFn, false)
|
||||
}
|
||||
|
||||
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
|
||||
// Use this when the data contains XML/HTML content that should be preserved as-is.
|
||||
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.outFormat(data, meta, prettyFn, true)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
if ctx.JqExpr != "" {
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
@@ -501,10 +535,10 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
}
|
||||
case "json", "":
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
@@ -595,6 +629,9 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
registerShortcutFlags(cmd, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
parent.AddCommand(cmd)
|
||||
if shortcut.PostMount != nil {
|
||||
shortcut.PostMount(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
@@ -860,7 +897,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
}
|
||||
if len(fl.Enum) > 0 {
|
||||
vals := fl.Enum
|
||||
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
@@ -876,11 +913,11 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if s.HasFormat {
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
98
shortcuts/common/runner_flag_completion_test.go
Normal file
98
shortcuts/common/runner_flag_completion_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestShortcutMount_FlagCompletionsRegistered exercises the two
|
||||
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
|
||||
// the per-flag enum completion (runner.go:879) and the auto-injected --format
|
||||
// completion (runner.go:895).
|
||||
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(false)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
|
||||
// Enum flag completion.
|
||||
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --sort-by")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
|
||||
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
|
||||
}
|
||||
|
||||
// HasFormat-injected --format completion.
|
||||
fn, ok = cmd.GetFlagCompletionFunc("format")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --format")
|
||||
}
|
||||
got, _ = fn(cmd, nil, "")
|
||||
want := []string{"json", "pretty", "table", "ndjson", "csv"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("format completion = %v, want %v", got, want)
|
||||
}
|
||||
for i, v := range want {
|
||||
if got[i] != v {
|
||||
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
|
||||
// prevents the two registrations from landing in cobra's global map.
|
||||
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(true)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
|
||||
t.Fatal("did not expect completion func for --sort-by when disabled")
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package common
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Flag.Input source constants.
|
||||
const (
|
||||
@@ -43,6 +47,11 @@ type Shortcut struct {
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// PostMount is an optional hook called after the cobra.Command is fully
|
||||
// configured (flags registered, tips set) but before it is added to the
|
||||
// parent. Use it to install custom help functions or tweak the command.
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
// ScopesForIdentity returns the scopes applicable for the given identity.
|
||||
|
||||
@@ -20,6 +20,18 @@ var alignMap = map[string]int{
|
||||
"right": 3,
|
||||
}
|
||||
|
||||
// fileViewMap maps the user-facing --file-view value to the docx File block
|
||||
// `view_type` enum. The underlying values come from the open platform spec:
|
||||
//
|
||||
// 1 = card view (default)
|
||||
// 2 = preview view (renders audio/video files as an inline player)
|
||||
// 3 = inline view
|
||||
var fileViewMap = map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
|
||||
var DocMediaInsert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-insert",
|
||||
@@ -33,6 +45,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
{Name: "type", Default: "image", Desc: "type: image | file"},
|
||||
{Name: "align", Desc: "alignment: left | center | right"},
|
||||
{Name: "caption", Desc: "image caption text"},
|
||||
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -42,6 +55,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
if docRef.Kind == "doc" {
|
||||
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
}
|
||||
if view := runtime.Str("file-view"); view != "" {
|
||||
if _, ok := fileViewMap[view]; !ok {
|
||||
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
|
||||
}
|
||||
if runtime.Str("type") != "file" {
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,9 +76,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
mediaType := runtime.Str("type")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
parentType := parentTypeForMediaType(mediaType)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
|
||||
createBlockData["index"] = "<children_len>"
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
|
||||
|
||||
@@ -92,6 +114,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
mediaType := runtime.Str("type")
|
||||
alignStr := runtime.Str("align")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
documentID, err := resolveDocxDocumentID(runtime, docInput)
|
||||
if err != nil {
|
||||
@@ -132,7 +155,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
|
||||
createData, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildCreateBlockData(mediaType, insertIndex))
|
||||
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,12 +231,22 @@ func parentTypeForMediaType(mediaType string) string {
|
||||
return "docx_image"
|
||||
}
|
||||
|
||||
func buildCreateBlockData(mediaType string, index int) map[string]interface{} {
|
||||
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
|
||||
child := map[string]interface{}{
|
||||
"block_type": blockTypeForMediaType(mediaType),
|
||||
}
|
||||
if mediaType == "file" {
|
||||
child["file"] = map[string]interface{}{}
|
||||
fileData := map[string]interface{}{}
|
||||
// view_type can only be set at block creation time; the PATCH
|
||||
// replace_file endpoint does not accept it, so if the caller wants
|
||||
// preview/inline rendering we must wire it in here. Whitelist the
|
||||
// concrete enum values so a stray positive int cannot produce a
|
||||
// malformed payload if Validate is ever bypassed.
|
||||
switch fileViewType {
|
||||
case 1, 2, 3:
|
||||
fileData["view_type"] = fileViewType
|
||||
}
|
||||
child["file"] = fileData
|
||||
} else {
|
||||
child["image"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 3)
|
||||
got := buildCreateBlockData("image", 3, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -29,7 +35,7 @@ func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 1)
|
||||
got := buildCreateBlockData("file", 1, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -44,6 +50,113 @@ func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The `--file-view card` path sends a different request shape than
|
||||
// omitting the flag entirely: omitting produces `file: {}`, while
|
||||
// `card` produces `file: {view_type: 1}`. The two are intended to be
|
||||
// semantically equivalent at the API level, but the on-the-wire payload
|
||||
// is different and is part of the public flag contract, so pin it down.
|
||||
func TestBuildCreateBlockDataForFileWithCardView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 1) // card
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, card) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithPreviewView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 2) // preview
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithInlineView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 3) // inline
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, inline) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// view_type must never leak into non-file blocks even if the caller
|
||||
// accidentally passes a non-zero fileViewType alongside --type=image.
|
||||
func TestBuildCreateBlockDataForImageIgnoresFileViewType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 0, 2)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 27,
|
||||
"image": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(image, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileViewMapCoversDocumentedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Assert only the documented keys — leave room for future aliases
|
||||
// (e.g. a "player" synonym for preview) without breaking this test.
|
||||
want := map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
for key, expected := range want {
|
||||
got, ok := fileViewMap[key]
|
||||
if !ok {
|
||||
t.Errorf("fileViewMap missing required key %q", key)
|
||||
continue
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("fileViewMap[%q] = %d, want %d", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -161,3 +274,98 @@ func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) {
|
||||
t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner")
|
||||
}
|
||||
}
|
||||
|
||||
// newMediaInsertValidateRuntime builds a bare RuntimeContext wired with
|
||||
// only the flags that DocMediaInsert.Validate reads. It exists so the
|
||||
// Validate tests below can exercise the CLI contract without going
|
||||
// through the full cobra command tree.
|
||||
func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("file-view", "", "")
|
||||
if err := cmd.Flags().Set("doc", doc); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if fileView != "" {
|
||||
if err := cmd.Flags().Set("file-view", fileView); err != nil {
|
||||
t.Fatalf("set --file-view: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// Validate is the real user-facing contract for --file-view: unknown
|
||||
// values must be rejected, and passing the flag alongside --type!=file
|
||||
// must also be rejected. buildCreateBlockData tests alone cannot catch
|
||||
// regressions here, so lock the guard logic down explicitly.
|
||||
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
fileView string
|
||||
wantErr string // substring; empty means success expected
|
||||
}{
|
||||
{
|
||||
name: "file with card is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "card",
|
||||
},
|
||||
{
|
||||
name: "file with preview is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "preview",
|
||||
},
|
||||
{
|
||||
name: "file with inline is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "inline",
|
||||
},
|
||||
{
|
||||
name: "file without file-view is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "",
|
||||
},
|
||||
{
|
||||
name: "unknown file-view value is rejected",
|
||||
mediaType: "file",
|
||||
fileView: "bogus",
|
||||
wantErr: "invalid --file-view value",
|
||||
},
|
||||
{
|
||||
name: "file-view with image type is rejected",
|
||||
mediaType: "image",
|
||||
fileView: "preview",
|
||||
wantErr: "--file-view only applies when --type=file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, ttTemp := range tests {
|
||||
tt := ttTemp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntime(t, "doxcnValidateFileView", tt.mediaType, tt.fileView)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,35 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
|
||||
func v1CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title", Hidden: true},
|
||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
|
||||
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
|
||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
|
||||
|
||||
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Create(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("content") != "" ||
|
||||
runtime.Str("parent-token") != "" ||
|
||||
runtime.Str("parent-position") != ""
|
||||
}
|
||||
|
||||
var DocsCreate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+create",
|
||||
@@ -17,56 +43,85 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "document title"},
|
||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "folder-token", Desc: "parent folder token"},
|
||||
{Name: "wiki-node", Desc: "wiki node token"},
|
||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
},
|
||||
v1CreateFlags(),
|
||||
v2CreateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
count := 0
|
||||
if runtime.Str("folder-token") != "" {
|
||||
count++
|
||||
if useV2Create(runtime) {
|
||||
return validateCreateV2(ctx, runtime)
|
||||
}
|
||||
if runtime.Str("wiki-node") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-space") != "" {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
return validateCreateV1(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildDocsCreateArgs(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: create-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "create-doc").Set("args", args)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
if useV2Create(runtime) {
|
||||
return dryRunCreateV2(ctx, runtime)
|
||||
}
|
||||
return d
|
||||
return dryRunCreateV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := buildDocsCreateArgs(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
if useV2Create(runtime) {
|
||||
return executeCreateV2(ctx, runtime)
|
||||
}
|
||||
augmentDocsCreateResult(runtime, result)
|
||||
|
||||
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
return executeCreateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--markdown is required")
|
||||
}
|
||||
count := 0
|
||||
if runtime.Str("folder-token") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-node") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-space") != "" {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildCreateArgsV1(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: create-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "create-doc").Set("args", args)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
augmentCreateResultV1(runtime, result)
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"markdown": runtime.Str("markdown"),
|
||||
}
|
||||
@@ -90,18 +145,17 @@ type docsPermissionTarget struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
target := selectDocsPermissionTarget(result)
|
||||
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
target := selectPermissionTarget(result)
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
||||
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
||||
return ref
|
||||
}
|
||||
|
||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
||||
if docID != "" {
|
||||
return docsPermissionTarget{Token: docID, Type: "docx"}
|
||||
@@ -109,16 +163,14 @@ func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTar
|
||||
return docsPermissionTarget{}
|
||||
}
|
||||
|
||||
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
||||
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
||||
if strings.TrimSpace(docURL) == "" {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
|
||||
ref, err := parseDocumentRef(docURL)
|
||||
if err != nil {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
|
||||
switch ref.Kind {
|
||||
case "wiki":
|
||||
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
|
||||
@@ -128,3 +180,68 @@ func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
|
||||
// whiteboard creation markdown is detected.
|
||||
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
return
|
||||
}
|
||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
||||
}
|
||||
|
||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
||||
lower := strings.ToLower(markdown)
|
||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "<whiteboard") &&
|
||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
||||
}
|
||||
|
||||
func normalizeBoardTokens(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []string{}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
tokens := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
case string:
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{v}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
// concatFlags combines multiple flag slices into one.
|
||||
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||
var out []common.Flag
|
||||
for _, s := range slices {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
|
||||
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
|
||||
m := make(map[string]string, len(v1)+len(v2))
|
||||
for _, f := range v1 {
|
||||
m[f.Name] = "v1"
|
||||
}
|
||||
for _, f := range v2 {
|
||||
m[f.Name] = "v2"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -9,15 +9,182 @@ import (
|
||||
"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 TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"member": map[string]interface{}{
|
||||
"member_id": "ou_current_user",
|
||||
"member_type": "openid",
|
||||
"perm": "full_access",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>项目计划</title><h1>目标</h1>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(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 document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to parse permission request 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 (MCP) tests ──
|
||||
|
||||
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
@@ -59,77 +226,9 @@ func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
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 document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to parse permission request 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--markdown", "## 内容",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--markdown", "## 内容",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
@@ -164,12 +263,6 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
@@ -180,6 +273,8 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
t.Helper()
|
||||
|
||||
@@ -193,6 +288,18 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
|
||||
payload, _ := json.Marshal(result)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -214,15 +321,7 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
|
||||
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
|
||||
parent := &cobra.Command{Use: "docs"}
|
||||
DocsCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
|
||||
}
|
||||
|
||||
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
|
||||
88
shortcuts/doc/docs_create_v2.go
Normal file
88
shortcuts/doc/docs_create_v2.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
|
||||
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("content") == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCreateBody(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
Desc("OpenAPI: create document").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildCreateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stripBlockIDs(data)
|
||||
augmentDocsCreatePermission(runtime, data)
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"content": runtime.Str("content"),
|
||||
}
|
||||
if v := runtime.Str("parent-token"); v != "" {
|
||||
body["parent_token"] = v
|
||||
}
|
||||
if v := runtime.Str("parent-position"); v != "" {
|
||||
body["parent_position"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
// augmentDocsCreatePermission grants full_access to the current CLI user when
|
||||
// the document was created with bot identity.
|
||||
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
|
||||
if docID == "" {
|
||||
return
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
|
||||
data["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,45 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
|
||||
func v1FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "offset", Desc: "pagination offset", Hidden: true},
|
||||
{Name: "limit", Desc: "pagination limit", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
|
||||
|
||||
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
|
||||
// flags with non-default values (bare "--doc xxx" stays on v1).
|
||||
func useV2Fetch(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
|
||||
// Only trigger auto-detect when a non-default value is present.
|
||||
if d := runtime.Str("detail"); d != "" && d != "simple" {
|
||||
return true
|
||||
}
|
||||
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
|
||||
return true
|
||||
}
|
||||
if runtime.Int("revision-id") != -1 {
|
||||
return true
|
||||
}
|
||||
if m := runtime.Str("scope"); m != "" && m != "full" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var DocsFetch = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
@@ -20,62 +56,81 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "offset", Desc: "pagination offset"},
|
||||
{Name: "limit", Desc: "pagination limit"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1FetchFlags(),
|
||||
v2FetchFlags(),
|
||||
),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
if useV2Fetch(runtime) {
|
||||
return dryRunFetchV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: fetch-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
||||
return dryRunFetchV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
if useV2Fetch(runtime) {
|
||||
return executeFetchV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
result["markdown"] = fixExportedMarkdown(md)
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
if title, ok := result["title"].(string); ok && title != "" {
|
||||
fmt.Fprintf(w, "# %s\n\n", title)
|
||||
}
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
fmt.Fprintln(w, md)
|
||||
}
|
||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
return executeFetchV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildFetchArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: fetch-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+fetch")
|
||||
args := buildFetchArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
result["markdown"] = fixExportedMarkdown(md)
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
if title, ok := result["title"].(string); ok && title != "" {
|
||||
fmt.Fprintf(w, "# %s\n\n", title)
|
||||
}
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
fmt.Fprintln(w, md)
|
||||
}
|
||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
194
shortcuts/doc/docs_fetch_v2.go
Normal file
194
shortcuts/doc/docs_fetch_v2.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||||
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
|
||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
|
||||
}
|
||||
body := buildFetchBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiPath).
|
||||
Desc("OpenAPI: fetch document").
|
||||
Body(body).
|
||||
Set("document_id", ref.Token)
|
||||
}
|
||||
|
||||
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
body := buildFetchBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", apiPath, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
if content, ok := doc["content"].(string); ok {
|
||||
fmt.Fprintln(w, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
|
||||
detail := runtime.Str("detail")
|
||||
switch detail {
|
||||
case "", "simple":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": false,
|
||||
"export_style_attrs": false,
|
||||
"export_cite_extra_data": false,
|
||||
}
|
||||
case "with-ids":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
case "full":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
}
|
||||
|
||||
if ro := buildReadOption(runtime); ro != nil {
|
||||
body["read_option"] = ro
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
ro := map[string]interface{}{"read_mode": mode}
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
ro["start_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
|
||||
ro["end_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
|
||||
ro["keyword"] = v
|
||||
}
|
||||
if v := runtime.Int("context-before"); v > 0 {
|
||||
ro["context_before"] = strconv.Itoa(v)
|
||||
}
|
||||
if v := runtime.Int("context-after"); v > 0 {
|
||||
ro["context_after"] = strconv.Itoa(v)
|
||||
}
|
||||
if v := runtime.Int("max-depth"); v >= 0 {
|
||||
ro["max_depth"] = strconv.Itoa(v)
|
||||
}
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return nil
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v := runtime.Int("context-before"); v < 0 {
|
||||
return fmt.Errorf("--context-before must be >= 0, got %d", v)
|
||||
}
|
||||
if v := runtime.Int("context-after"); v < 0 {
|
||||
return fmt.Errorf("--context-after must be >= 0, got %d", v)
|
||||
}
|
||||
if v := runtime.Int("max-depth"); v < -1 {
|
||||
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "outline":
|
||||
return nil
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
|
||||
}
|
||||
return nil
|
||||
case "keyword":
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return fmt.Errorf("keyword mode requires --keyword")
|
||||
}
|
||||
return nil
|
||||
case "section":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
|
||||
return fmt.Errorf("section mode requires --start-block-id")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --scope %q", mode)
|
||||
}
|
||||
}
|
||||
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
|
||||
runtime := newFetchBodyTestRuntime(ctx)
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newCreateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newUpdateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildUpdateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["scene"]; ok {
|
||||
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
@@ -5,12 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var validModes = map[string]bool{
|
||||
var validModesV1 = map[string]bool{
|
||||
"append": true,
|
||||
"overwrite": true,
|
||||
"replace_range": true,
|
||||
@@ -20,7 +21,7 @@ var validModes = map[string]bool{
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
var needsSelection = map[string]bool{
|
||||
var needsSelectionV1 = map[string]bool{
|
||||
"replace_range": true,
|
||||
"replace_all": true,
|
||||
"insert_before": true,
|
||||
@@ -28,6 +29,32 @@ var needsSelection = map[string]bool{
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
|
||||
func v1UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
|
||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
|
||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
|
||||
{Name: "new-title", Desc: "also update document title", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
|
||||
|
||||
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Update(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("command") != "" ||
|
||||
runtime.Str("content") != "" ||
|
||||
runtime.Str("pattern") != "" ||
|
||||
runtime.Str("block-id") != "" ||
|
||||
runtime.Str("src-block-ids") != ""
|
||||
}
|
||||
|
||||
var DocsUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+update",
|
||||
@@ -35,124 +62,104 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
|
||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
|
||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
|
||||
{Name: "new-title", Desc: "also update document title"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1UpdateFlags(),
|
||||
v2UpdateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
mode := runtime.Str("mode")
|
||||
if !validModes[mode] {
|
||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
||||
if useV2Update(runtime) {
|
||||
return validateUpdateV2(ctx, runtime)
|
||||
}
|
||||
|
||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
||||
}
|
||||
|
||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
||||
selTitle := runtime.Str("selection-by-title")
|
||||
if selEllipsis != "" && selTitle != "" {
|
||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
||||
}
|
||||
|
||||
if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateUpdateV1(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
if useV2Update(runtime) {
|
||||
return dryRunUpdateV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: update-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "update-doc").Set("args", args)
|
||||
return dryRunUpdateV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
if useV2Update(runtime) {
|
||||
return executeUpdateV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
return executeUpdateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
return
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
mode := runtime.Str("mode")
|
||||
if mode == "" {
|
||||
return common.FlagErrorf("--mode is required")
|
||||
}
|
||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
||||
if !validModesV1[mode] {
|
||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
||||
}
|
||||
|
||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
||||
}
|
||||
|
||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
||||
selTitle := runtime.Str("selection-by-title")
|
||||
if selEllipsis != "" && selTitle != "" {
|
||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
||||
lower := strings.ToLower(markdown)
|
||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "<whiteboard") &&
|
||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
||||
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: update-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "update-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func normalizeBoardTokens(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []string{}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
tokens := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
case string:
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{v}
|
||||
default:
|
||||
return []string{}
|
||||
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+update")
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -7,6 +7,32 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── V2 tests ──
|
||||
|
||||
func TestValidCommandsV2(t *testing.T) {
|
||||
expected := map[string]bool{
|
||||
"str_replace": true,
|
||||
"str_delete": true,
|
||||
"block_delete": true,
|
||||
"block_insert_after": true,
|
||||
"block_copy_insert_after": true,
|
||||
"block_replace": true,
|
||||
"block_move_after": true,
|
||||
"overwrite": true,
|
||||
"append": true,
|
||||
}
|
||||
if len(validCommandsV2) != len(expected) {
|
||||
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
|
||||
}
|
||||
for cmd := range validCommandsV2 {
|
||||
if !expected[cmd] {
|
||||
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 tests ──
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
@@ -30,13 +56,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
if !ok {
|
||||
@@ -52,7 +78,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
"board_tokens": []interface{}{"board_1", "board_2"},
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
want := []string{"board_1", "board_2"}
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
@@ -69,7 +95,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
"success": true,
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "## plain text")
|
||||
normalizeWhiteboardResult(result, "## plain text")
|
||||
|
||||
if _, ok := result["board_tokens"]; ok {
|
||||
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
|
||||
|
||||
174
shortcuts/doc/docs_update_v2.go
Normal file
174
shortcuts/doc/docs_update_v2.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var validCommandsV2 = map[string]bool{
|
||||
"str_replace": true,
|
||||
"str_delete": true,
|
||||
"block_delete": true,
|
||||
"block_insert_after": true,
|
||||
"block_copy_insert_after": true,
|
||||
"block_replace": true,
|
||||
"block_move_after": true,
|
||||
"overwrite": true,
|
||||
"append": true,
|
||||
}
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
|
||||
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
|
||||
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
|
||||
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func validCommandsV2Keys() []string {
|
||||
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
|
||||
}
|
||||
|
||||
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
cmd := runtime.Str("command")
|
||||
if cmd == "" {
|
||||
return common.FlagErrorf("--command is required")
|
||||
}
|
||||
if !validCommandsV2[cmd] {
|
||||
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
pattern := runtime.Str("pattern")
|
||||
blockID := runtime.Str("block-id")
|
||||
srcBlockIDs := runtime.Str("src-block-ids")
|
||||
|
||||
switch cmd {
|
||||
case "str_replace":
|
||||
if pattern == "" {
|
||||
return common.FlagErrorf("--command str_replace requires --pattern")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command str_replace requires --content")
|
||||
}
|
||||
case "str_delete":
|
||||
if pattern == "" {
|
||||
return common.FlagErrorf("--command str_delete requires --pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_delete requires --block-id")
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --content")
|
||||
}
|
||||
case "block_copy_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
|
||||
}
|
||||
if srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
|
||||
}
|
||||
case "block_move_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --block-id")
|
||||
}
|
||||
if content == "" && srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
|
||||
}
|
||||
case "block_replace":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --content")
|
||||
}
|
||||
case "overwrite":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command overwrite requires --content")
|
||||
}
|
||||
case "append":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command append requires --content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
|
||||
}
|
||||
body := buildUpdateBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
Desc("OpenAPI: update document").
|
||||
Body(body).
|
||||
Set("document_id", ref.Token)
|
||||
}
|
||||
|
||||
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cmd := runtime.Str("command")
|
||||
|
||||
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
|
||||
blockID := runtime.Str("block-id")
|
||||
if cmd == "append" {
|
||||
cmd = "block_insert_after"
|
||||
blockID = "-1"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"command": cmd,
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v != 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
if v := runtime.Str("content"); v != "" {
|
||||
body["content"] = v
|
||||
}
|
||||
if v := runtime.Str("pattern"); v != "" {
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
body["src_block_ids"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
@@ -4,12 +4,18 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
|
||||
// scene without exposing it as a user-controlled CLI flag.
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -56,6 +62,40 @@ func extractDocumentToken(raw, marker string) (string, bool) {
|
||||
return token, true
|
||||
}
|
||||
|
||||
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
|
||||
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
|
||||
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
|
||||
return runtime.DoAPIJSON(method, apiPath, nil, body)
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
scene, _ := ctx.Value(docsSceneContextKey).(string)
|
||||
return strings.TrimSpace(scene)
|
||||
}
|
||||
|
||||
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
|
||||
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
|
||||
body["scene"] = scene
|
||||
}
|
||||
}
|
||||
|
||||
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
|
||||
func stripBlockIDs(data map[string]interface{}) {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
blocks, _ := doc["newblocks"].([]interface{})
|
||||
for _, b := range blocks {
|
||||
if m, ok := b.(map[string]interface{}); ok {
|
||||
delete(m, "block_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveRouteExtra(docID string) (string, error) {
|
||||
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
|
||||
if err != nil {
|
||||
|
||||
52
shortcuts/doc/versioned_help.go
Normal file
52
shortcuts/doc/versioned_help.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// installVersionedHelp sets a custom help function on cmd that shows only the
|
||||
// flags relevant to the selected --api-version. flagVersions maps flag name to
|
||||
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
|
||||
// always visible.
|
||||
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
|
||||
origHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
ver, _ := cmd.Flags().GetString("api-version")
|
||||
if ver == "" {
|
||||
ver = defaultVersion
|
||||
}
|
||||
// Show/hide flags based on the active version.
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if fv, ok := flagVersions[f.Name]; ok {
|
||||
f.Hidden = fv != ver
|
||||
}
|
||||
})
|
||||
origHelp(cmd, args)
|
||||
if ver == "v1" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
|
||||
" Use --api-version v2 for the latest API:\n"+
|
||||
" %s %s --api-version v2 --help\n"+
|
||||
" Upgrade skill:\n"+
|
||||
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
|
||||
cmd.Parent().Name(), cmd.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
|
||||
// path is used, guiding users to upgrade their skill to v2.
|
||||
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
|
||||
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
|
||||
shortcut)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
|
||||
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docx:document:readonly",
|
||||
@@ -72,14 +73,15 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
|
||||
{Name: "doc", Desc: "document URL/token, sheet URL, or wiki URL that resolves to doc/docx/sheet", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +90,21 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -99,37 +116,69 @@ var DriveAddComment = common.Shortcut{
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
|
||||
return output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
|
||||
// For wiki URLs, resolve the actual target type via API so dry-run
|
||||
// matches real execution behavior instead of guessing from --block-id.
|
||||
resolvedKind := docRef.Kind
|
||||
resolvedToken := docRef.Token
|
||||
isWiki := false
|
||||
if docRef.Kind == "wiki" {
|
||||
isWiki = true
|
||||
target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), commentModeFull)
|
||||
if err == nil {
|
||||
resolvedKind = target.FileType
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
if anchor == nil {
|
||||
anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0}
|
||||
}
|
||||
commentBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
desc := "1-step request: create sheet comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create sheet comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
|
||||
targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)
|
||||
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
|
||||
commentBody := buildCommentCreateV2Request(resolvedKind, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
|
||||
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
|
||||
}
|
||||
|
||||
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
switch {
|
||||
case mode == commentModeFull && resolvedBy == "wiki":
|
||||
case mode == commentModeFull && isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
|
||||
case mode == commentModeFull:
|
||||
dry.Desc("1-step request: create full comment")
|
||||
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
|
||||
case isWiki && strings.TrimSpace(selection) != "":
|
||||
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
|
||||
case resolvedBy == "wiki":
|
||||
case isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
|
||||
case strings.TrimSpace(selection) != "":
|
||||
dry.Desc("2-step orchestration: locate block -> create local comment")
|
||||
@@ -137,19 +186,17 @@ var DriveAddComment = common.Shortcut{
|
||||
dry.Desc("1-step request: create local comment with explicit block ID")
|
||||
}
|
||||
|
||||
if resolvedBy == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to target document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
|
||||
if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
|
||||
step := "[1]"
|
||||
if resolvedBy == "wiki" {
|
||||
if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
docID := resolvedToken
|
||||
if isWiki && resolvedToken == docRef.Token {
|
||||
docID = "<resolved_docx_token>"
|
||||
}
|
||||
mcpArgs := map[string]interface{}{
|
||||
"doc_id": dryRunLocateDocRef(docRef),
|
||||
"doc_id": docID,
|
||||
"limit": defaultLocateDocLimit,
|
||||
"selection_with_ellipsis": selection,
|
||||
}
|
||||
@@ -171,23 +218,29 @@ var DriveAddComment = common.Shortcut{
|
||||
if mode == commentModeLocal {
|
||||
createDesc = "Create local comment"
|
||||
step = "[2]"
|
||||
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
|
||||
if isWiki && strings.TrimSpace(selection) != "" {
|
||||
step = "[3]"
|
||||
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
|
||||
} else if isWiki || strings.TrimSpace(selection) != "" {
|
||||
step = "[2]"
|
||||
} else {
|
||||
step = "[1]"
|
||||
}
|
||||
} else if resolvedBy == "wiki" {
|
||||
} else if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
|
||||
return dry.POST(createPath).
|
||||
Desc(step+" "+createDesc).
|
||||
Body(commentBody).
|
||||
Set("file_token", targetToken)
|
||||
Set("file_token", resolvedToken)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
@@ -197,6 +250,11 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Wiki resolved to sheet: redirect to sheet comment path.
|
||||
if target.FileType == "sheet" {
|
||||
return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken})
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,9 +283,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
|
||||
}
|
||||
|
||||
if mode == commentModeLocal {
|
||||
@@ -288,7 +346,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
|
||||
return commentModeLocal
|
||||
}
|
||||
|
||||
func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
@@ -297,6 +355,9 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/wiki/"); ok {
|
||||
return commentDocRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/docx/"); ok {
|
||||
return commentDocRef{Kind: "docx", Token: token}, nil
|
||||
}
|
||||
@@ -304,40 +365,29 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
return commentDocRef{Kind: "docx", Token: raw}, nil
|
||||
}
|
||||
|
||||
func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) {
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, "docx", "docx"
|
||||
case "doc":
|
||||
return docRef.Token, "doc", "doc"
|
||||
case "wiki":
|
||||
if mode == commentModeFull {
|
||||
return "<resolved_file_token>", "<resolved_file_type>", "wiki"
|
||||
}
|
||||
return "<resolved_docx_token>", "docx", "wiki"
|
||||
default:
|
||||
return "<resolved_docx_token>", "docx", "docx"
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) {
|
||||
docRef, err := parseCommentDocRef(input)
|
||||
docRef, err := parseCommentDocRef(input, runtime.Str("type"))
|
||||
if err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -364,11 +414,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "" || objToken == "" {
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "sheet",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -531,12 +592,24 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
return replyElements, nil
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
|
||||
type sheetAnchor struct {
|
||||
SheetID string
|
||||
Col int
|
||||
Row int
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"reply_elements": replyElements,
|
||||
}
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
if sheet != nil {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": sheet.SheetID,
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -551,13 +624,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func dryRunLocateDocRef(docRef commentDocRef) string {
|
||||
if docRef.Kind == "wiki" {
|
||||
return "<resolved_docx_token>"
|
||||
}
|
||||
return docRef.Token
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -576,6 +642,83 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSheetCellRef parses "<sheetId>!<cell>" (e.g. "a281f9!D6") into a sheetAnchor.
|
||||
// Column is converted from letter to 0-based index (A=0), row from 1-based to 0-based.
|
||||
func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
parts := strings.SplitN(input, "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse cell reference like "D6" into col letter + row number.
|
||||
i := 0
|
||||
for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
|
||||
// Column letter to 0-based index: A=0, B=1, ..., Z=25, AA=26.
|
||||
col := 0
|
||||
for _, ch := range colStr {
|
||||
col = col*26 + int(ch-'A'+1)
|
||||
}
|
||||
col-- // convert to 0-based
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(docRef.Token))
|
||||
requestBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
|
||||
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"file_token": docRef.Token,
|
||||
"file_type": "sheet",
|
||||
"comment_mode": "sheet",
|
||||
"block_id": blockID,
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractURLToken(raw, marker string) (string, bool) {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -6,6 +6,9 @@ package drive
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseCommentDocRef(t *testing.T) {
|
||||
@@ -14,6 +17,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
docType string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
@@ -31,11 +35,31 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token treated as docx",
|
||||
name: "raw token with type docx",
|
||||
input: "xxxxxx",
|
||||
docType: "docx",
|
||||
wantKind: "docx",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token with type sheet",
|
||||
input: "shtToken",
|
||||
docType: "sheet",
|
||||
wantKind: "sheet",
|
||||
wantToken: "shtToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type doc",
|
||||
input: "docToken",
|
||||
docType: "doc",
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "old doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -53,7 +77,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentDocRef(tt.input)
|
||||
got, err := parseCommentDocRef(tt.input, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -249,7 +273,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
"text": "全文评论",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -279,7 +303,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
"text": "评论内容",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -300,3 +324,540 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Fatalf("unexpected reply element: %#v", gotReplyElements[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment tests ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseCommentDocRefSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefSheetWithQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "请修正此单元格"},
|
||||
}
|
||||
got := buildCommentCreateV2Request("sheet", "", replyElements, &sheetAnchor{
|
||||
SheetID: "abc123",
|
||||
Col: 3,
|
||||
Row: 5,
|
||||
})
|
||||
|
||||
if got["file_type"] != "sheet" {
|
||||
t.Fatalf("expected file_type sheet, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "abc123" {
|
||||
t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["sheet_col"] != 3 {
|
||||
t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"])
|
||||
}
|
||||
if anchor["sheet_row"] != 5 {
|
||||
t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "test"},
|
||||
}
|
||||
// When both sheet anchor and blockID are provided, sheet anchor wins.
|
||||
got := buildCommentCreateV2Request("sheet", "should_be_ignored", replyElements, &sheetAnchor{
|
||||
SheetID: "s1",
|
||||
Col: 0,
|
||||
Row: 0,
|
||||
})
|
||||
anchor := got["anchor"].(map[string]interface{})
|
||||
if anchor["block_id"] != "s1" {
|
||||
t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"])
|
||||
}
|
||||
if _, exists := anchor["sheet_col"]; !exists {
|
||||
t.Fatal("expected sheet_col in anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
sheetID string
|
||||
col int
|
||||
row int
|
||||
}{
|
||||
{"A1", "s1!A1", "s1", 0, 0},
|
||||
{"D6", "abc!D6", "abc", 3, 5},
|
||||
{"AA1", "s1!AA1", "s1", 26, 0},
|
||||
{"lowercase", "s1!d6", "s1", 3, 5},
|
||||
{"B10", "sheet1!B10", "sheet1", 1, 9},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSheetCellRef(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row {
|
||||
t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef(input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment validate tests ────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "no-exclamation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<sheetId>!<cell>") {
|
||||
t.Fatalf("expected format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--full-comment",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment execute tests ─────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"请检查"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "comment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "c456"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "abc!A1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiResolve(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiSheetComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"wiki sheet comment"}]`,
|
||||
"--block-id", "s1!B3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiSheetComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSheet(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToDocxFull(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxLocalWithBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "local comment") {
|
||||
t.Fatalf("dry-run output missing local comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiDocxComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiDocxComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiIncompleteNodeData(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete node error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/doc/oldDocToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx and sheet") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional unit function tests ──────────────────────────────────────────
|
||||
|
||||
func TestAnchorBlockIDForDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" {
|
||||
t.Fatalf("expected blk_123, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(""); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder for whitespace, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefRowZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef("s1!A0")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be >= 1") {
|
||||
t.Fatalf("expected row validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefPathLikeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCommentDocRef("token/with/slash", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") {
|
||||
t.Fatalf("expected unsupported doc error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty token after marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
136
shortcuts/drive/drive_create_shortcut.go
Normal file
136
shortcuts/drive/drive_create_shortcut.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var driveCreateShortcutAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
type driveCreateShortcutSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
|
||||
return driveCreateShortcutSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestBody builds the create_shortcut API payload from the shortcut spec.
|
||||
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"parent_token": s.FolderToken,
|
||||
"refer_entity": map[string]interface{}{
|
||||
"refer_token": s.FileToken,
|
||||
"refer_type": s.FileType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
|
||||
var DriveCreateShortcut = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-shortcut",
|
||||
Description: "Create a Drive shortcut in another folder",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:shortcut"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "source file token to reference", Required: true},
|
||||
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a Drive shortcut").
|
||||
POST("/open-apis/drive/v1/files/create_shortcut").
|
||||
Desc("[1] Create shortcut").
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Creating shortcut for %s %s in folder %s...\n",
|
||||
spec.FileType,
|
||||
common.MaskToken(spec.FileToken),
|
||||
common.MaskToken(spec.FolderToken),
|
||||
)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_shortcut",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"source_file_token": spec.FileToken,
|
||||
"source_type": spec.FileType,
|
||||
"folder_token": spec.FolderToken,
|
||||
}
|
||||
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
|
||||
out["shortcut_token"] = shortcutToken
|
||||
}
|
||||
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
|
||||
out["title"] = title
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
|
||||
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
|
||||
}
|
||||
if spec.FileType == "folder" {
|
||||
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
|
||||
}
|
||||
if !driveCreateShortcutAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
|
||||
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateShortcutSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "wiki",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "underlying file token first",
|
||||
},
|
||||
{
|
||||
name: "folder",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "folder_token_test",
|
||||
FileType: "folder",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "not folders",
|
||||
},
|
||||
{
|
||||
name: "shortcut",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "shortcut_token_test",
|
||||
FileType: "shortcut",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
{
|
||||
name: "missing folder token",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "docx",
|
||||
},
|
||||
wantErr: "--folder-token must not be empty",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unknown",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateShortcutSpec(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
|
||||
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveCreateShortcut.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"`
|
||||
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" {
|
||||
t.Fatalf("first method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
|
||||
}
|
||||
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
|
||||
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"succ_shortcut_node": map[string]interface{}{
|
||||
"token": "shortcut_token_test",
|
||||
"name": "shortcut_name_test",
|
||||
"type": "docx",
|
||||
"parent_token": "folder_target_token_test",
|
||||
"url": "https://example.feishu.cn/docx/shortcut_token_test",
|
||||
"shortcut_info": map[string]interface{}{
|
||||
"target_type": "docx",
|
||||
"target_token": "doc_token_test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", " doc_token_test ",
|
||||
"--type", " DOCX ",
|
||||
"--folder-token", " folder_target_token_test ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
|
||||
}
|
||||
referEntity, _ := body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["shortcut_token"] != "shortcut_token_test" {
|
||||
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
|
||||
}
|
||||
if data["folder_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
|
||||
}
|
||||
if data["source_file_token"] != "doc_token_test" {
|
||||
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
|
||||
}
|
||||
if data["title"] != "shortcut_name_test" {
|
||||
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
|
||||
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
|
||||
}
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
|
||||
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
|
||||
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
|
||||
FileToken: "doc_token_test",
|
||||
FileType: "docx",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
|
||||
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveCreateShortcut.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
|
||||
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
msg string
|
||||
wantType string
|
||||
wantHint string
|
||||
wantMsgPart string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: output.LarkErrDriveResourceContention,
|
||||
msg: "resource contention occurred, please retry",
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
wantMsgPart: "resource contention occurred",
|
||||
},
|
||||
{
|
||||
name: "cross tenant and unit",
|
||||
code: output.LarkErrDriveCrossTenantUnit,
|
||||
msg: "cross tenant and unit not support",
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
wantMsgPart: "cross tenant and unit not support",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: output.LarkErrDriveCrossBrand,
|
||||
msg: "cross brand not support",
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
wantMsgPart: "cross brand not support",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(tt.code),
|
||||
"msg": tt.msg,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", "doc_token_test",
|
||||
"--type", "docx",
|
||||
"--folder-token", "folder_token_test",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
}
|
||||
if exitErr.Detail.Code != tt.code {
|
||||
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,31 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, and folder move flows.
|
||||
// by Drive import, export, folder move/delete, and wiki move flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, move, or delete operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, or wiki move operations",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check":
|
||||
case "task_check", "wiki_move":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for task_check scenario")
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
@@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateDriveTaskResultScopes(ctx, runtime, scenario)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
@@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
case "wiki_move":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki move task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryExportTask(runtime, ticket, fileToken)
|
||||
case "task_check":
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
case "wiki_move":
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i
|
||||
"failed": status.Failed(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error {
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err != nil {
|
||||
// Propagate cancellation/timeout so callers stop instead of falling through
|
||||
// to the API call. Other token errors are non-fatal here: the API call will
|
||||
// surface a clearer permission error.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var required []string
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
return requireDriveScopes(result.Scopes, required)
|
||||
}
|
||||
|
||||
func requireDriveScopes(storedScopes string, required []string) error {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := missingDriveScopes(storedScopes, required)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
|
||||
func missingDriveScopes(storedScopes string, required []string) []string {
|
||||
granted := make(map[string]bool)
|
||||
for _, scope := range strings.Fields(storedScopes) {
|
||||
granted[scope] = true
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(required))
|
||||
for _, scope := range required {
|
||||
if !granted[scope] {
|
||||
missing = append(missing, scope)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
type wikiMoveTaskResultStatus struct {
|
||||
Node map[string]interface{}
|
||||
Status int
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
type wikiMoveTaskQueryStatus struct {
|
||||
TaskID string
|
||||
MoveResults []wikiMoveTaskResultStatus
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Ready() bool {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Failed() bool {
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.MoveResults[0]
|
||||
}
|
||||
|
||||
// primaryResult picks the most informative move_result for top-level status
|
||||
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
|
||||
// behind an earlier success, then a still-processing entry, and finally fall
|
||||
// back to the first entry.
|
||||
func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus {
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status < 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status > 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
return s.FirstResult()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
return r.Status
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getWikiMoveTaskStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"scenario": "wiki_move",
|
||||
"task_id": status.TaskID,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"status": status.PrimaryStatusCode(),
|
||||
"status_msg": status.PrimaryStatusLabel(),
|
||||
}
|
||||
|
||||
moveResults := make([]map[string]interface{}, 0, len(status.MoveResults))
|
||||
for _, result := range status.MoveResults {
|
||||
item := map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"status_msg": result.StatusMsg,
|
||||
}
|
||||
if result.Node != nil {
|
||||
item["node"] = result.Node
|
||||
}
|
||||
moveResults = append(moveResults, item)
|
||||
}
|
||||
if len(moveResults) > 0 {
|
||||
out["move_results"] = moveResults
|
||||
}
|
||||
|
||||
if first := status.FirstResult(); first != nil {
|
||||
// Mirror the first moved node at the top level so follow-up commands can
|
||||
// reuse a stable field set without digging into move_results[0].node.
|
||||
if first.Node != nil {
|
||||
out["node"] = first.Node
|
||||
appendWikiMoveNodeFields(out, first.Node)
|
||||
if token := common.GetString(first.Node, "node_token"); token != "" {
|
||||
out["wiki_token"] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, err
|
||||
}
|
||||
|
||||
return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
|
||||
for _, item := range common.GetSlice(task, "move_result") {
|
||||
resultMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{
|
||||
Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")),
|
||||
Status: int(common.GetFloat(resultMap, "status")),
|
||||
StatusMsg: common.GetString(resultMap, "status_msg"),
|
||||
})
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(node, "space_id"),
|
||||
"node_token": common.GetString(node, "node_token"),
|
||||
"obj_token": common.GetString(node, "obj_token"),
|
||||
"obj_type": common.GetString(node, "obj_type"),
|
||||
"parent_node_token": common.GetString(node, "parent_node_token"),
|
||||
"node_type": common.GetString(node, "node_type"),
|
||||
"origin_node_token": common.GetString(node, "origin_node_token"),
|
||||
"title": common.GetString(node, "title"),
|
||||
"has_child": common.GetBool(node, "has_child"),
|
||||
}
|
||||
}
|
||||
|
||||
func appendWikiMoveNodeFields(out, node map[string]interface{}) {
|
||||
if out == nil || node == nil {
|
||||
return
|
||||
}
|
||||
out["space_id"] = common.GetString(node, "space_id")
|
||||
out["node_token"] = common.GetString(node, "node_token")
|
||||
out["obj_token"] = common.GetString(node, "obj_token")
|
||||
out["obj_type"] = common.GetString(node, "obj_type")
|
||||
out["parent_node_token"] = common.GetString(node, "parent_node_token")
|
||||
out["node_type"] = common.GetString(node, "node_type")
|
||||
out["origin_node_token"] = common.GetString(node, "origin_node_token")
|
||||
out["title"] = common.GetString(node, "title")
|
||||
out["has_child"] = common.GetBool(node, "has_child")
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
{
|
||||
name: "wiki move missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "wiki_move",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -277,3 +287,259 @@ func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type mockDriveTaskResultTokenResolver struct {
|
||||
token string
|
||||
scopes string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
token := m.token
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_move"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_123"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.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 {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `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].Params["task_type"] != "move" {
|
||||
t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move",
|
||||
"--task-id", "task_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" {
|
||||
t.Fatalf("unexpected wiki_move envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" {
|
||||
t.Fatalf("flattened node fields missing: %#v", data)
|
||||
}
|
||||
moveResults, ok := data["move_results"].([]interface{})
|
||||
if !ok || len(moveResults) != 1 {
|
||||
t.Fatalf("move_results = %#v, want one result", data["move_results"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
|
||||
t.Fatalf("expected missing wiki scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err != nil {
|
||||
t.Fatalf("validateDriveTaskResultScopes() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "import")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") {
|
||||
t.Fatalf("expected missing drive scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" {
|
||||
t.Fatalf("parsed node = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiMoveTaskQueryStatus("task_123", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: -3, StatusMsg: "permission denied"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != -3 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "permission denied" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
|
||||
}
|
||||
// FirstResult must keep its literal "first entry" semantics for callers
|
||||
// that flatten node fields from the first move_result.
|
||||
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
|
||||
t.Fatalf("FirstResult = %+v, want first success entry", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != 1 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "processing" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelingTokenResolver struct{}
|
||||
|
||||
func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
|
||||
runtime.Factory = factory
|
||||
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateFolder,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
|
||||
@@ -5,12 +5,15 @@ package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-folder",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user