Compare commits

..

1 Commits

Author SHA1 Message Date
shanglei
67ee0defab fix(format): handle typed slices in table formatting and add regression tests 2026-04-14 16:38:06 +08:00
262 changed files with 2255 additions and 18060 deletions

View File

@@ -6,6 +6,3 @@ coverage:
patch:
default:
target: 60%
github_checks:
annotations: true

View File

@@ -1,116 +0,0 @@
name: Architecture Audit
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code detection
run: |
echo "## Dead Code" >> report.md
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
count=$(wc -l < deadcode.txt | tr -d ' ')
echo "Found **$count** unreachable functions" >> report.md
echo '```' >> report.md
cat deadcode.txt >> report.md
echo '```' >> report.md
- name: Package complexity
run: |
echo "## Package Complexity" >> report.md
echo "" >> report.md
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
echo "" >> report.md
echo "| Package | Files | Lines | Deps |" >> report.md
echo "|---------|-------|-------|------|" >> report.md
found=0
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
dir=$(go list -f '{{.Dir}}' "$pkg")
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
deps=$(go list -f '{{len .Imports}}' "$pkg")
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "| _(none)_ | | | |" >> report.md
fi
- name: Dependency freshness
run: |
echo "## Outdated Dependencies" >> report.md
echo '```' >> report.md
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md
echo "" >> report.md
echo "Shortcut domains without E2E tests:" >> report.md
echo "" >> report.md
found=0
for domain in $(ls -d shortcuts/*/); do
name=$(basename "$domain")
if [ "$name" = "common" ]; then continue; fi
if [ ! -d "tests/cli_e2e/$name" ]; then
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "All shortcut domains have E2E test directories." >> report.md
fi
- name: Coverage
run: |
echo "## Coverage" >> report.md
packages=$(go list ./... | grep -v 'tests/cli_e2e')
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
echo "Current total coverage: **${total:-n/a}**" >> report.md
- name: Publish report
run: |
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat report.md >> $GITHUB_STEP_SUMMARY
- name: Upload report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: arch-audit-${{ github.run_number }}
path: report.md
retention-days: 90

View File

@@ -1,334 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
fast-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check go.mod tidiness
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
lint:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
coverage:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
exit 1
fi
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code check (incremental)
run: |
# Analyze current HEAD (strip line:col for stable diff across line shifts)
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
# Analyze base branch via worktree
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
git worktree remove -f /tmp/dc-base 2>/dev/null || true
exit 0
}
git worktree remove -f /tmp/dc-base
# Only new dead code blocks the PR
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
if [ -s /tmp/dc-new.txt ]; then
echo "::group::New dead code"
cat /tmp/dc-new.txt
echo "::endgroup::"
echo "::error::New dead code detected — remove unreachable functions before merging"
exit 1
fi
echo "No new dead code introduced"
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Run dry-run E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Gitleaks
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
- name: govulncheck
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
license-header:
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::One or more CI jobs failed — see table above"
exit 1
fi

83
.github/workflows/cli-e2e.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

58
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Coverage
on:
push:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

28
.github/workflows/gitleaks.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

60
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Lint
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

43
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

View File

@@ -70,14 +70,6 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
shortcuts-no-raw-http:
files:
- "**/shortcuts/**"
deny:
- pkg: "net/http"
desc: >-
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
@@ -108,16 +100,6 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── output: shortcuts must use ctx.Out() ──
- pattern: fmt\.Print(f|ln)?\b
msg: >-
use ctx.Out() or ctx.OutFormat() for structured JSON output.
fmt.Print* bypasses the output envelope and breaks --jq/--format.
# ── logging: shortcuts must return errors, not log.Fatal ──
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
msg: >-
use structured error return, not log.Fatal/Panic.
Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-

View File

@@ -18,11 +18,9 @@ make test # Full: vet + unit + integration
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go vet ./...`
3. `gofmt -l .` — must produce no output
4. `go mod tidy` — must not change `go.mod`/`go.sum`
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
2. `go mod tidy` — must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -78,26 +76,3 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
### E2E Testing
**Dry-run E2E (required for every shortcut change)**
- Validates request structure without calling real APIs
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
- No secrets needed — runs on fork PRs
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
**Live E2E (required for new flows or behavior changes)**
- Validates real API round-trips
- Place in `tests/cli_e2e/<domain>/`
- Must be self-contained: create -> use -> cleanup
- Needs bot credentials (CI secrets, skipped on fork PRs)
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
| Change | Dry-run E2E | Live E2E |
|--------|:-----------:|:--------:|
| New shortcut | Required | Required |
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |

View File

@@ -2,82 +2,6 @@
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
@@ -404,10 +328,6 @@ 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

View File

@@ -30,7 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
@@ -38,7 +38,6 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
## Installation & Quick Start

View File

@@ -38,7 +38,6 @@
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐和指标 |
## 安装与快速开始

View File

@@ -24,7 +24,6 @@ type loginMsg struct {
WaitingAuth string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
ScopeMismatch string
ScopeHint string
RequestedScopes string
@@ -59,10 +58,9 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
AuthSuccess: "授权成,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
@@ -95,10 +93,9 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
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",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",

View File

@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// AuthorizedUser should contain two %s placeholders (userName, openId)
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
if got == msg.AuthorizedUser {
t.Errorf("%s AuthorizedUser has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {

View File

@@ -190,13 +190,13 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
if msg.AuthorizedUser != "" {
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)

View File

@@ -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{
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 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, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -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{
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
@@ -634,9 +634,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
@@ -746,7 +743,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 授权成功! 用户: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -774,7 +771,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
got := stderr.String()
for _, want := range []string{
"Authorization successful! User: tester (ou_user)",
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",

View File

@@ -10,12 +10,12 @@ import (
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
@@ -29,11 +29,11 @@ type initMsg struct {
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",

View File

@@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,

View File

@@ -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
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
)
@@ -17,13 +18,39 @@ var knownArrayFields = []string{
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
}
// isSliceLike reports whether v is any kind of slice (e.g. []interface{},
// []map[string]interface{}, []string, etc.), using reflect so that the
// check is not limited to a single concrete slice type.
func isSliceLike(v interface{}) bool {
if v == nil {
return false
}
return reflect.TypeOf(v).Kind() == reflect.Slice
}
// toGenericSlice converts any slice type to []interface{} by re-boxing each
// element. This only changes the outer container type; individual elements
// retain their original dynamic type (e.g. map[string]interface{} stays as-is).
// Returns nil if v is not a slice.
func toGenericSlice(v interface{}) []interface{} {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice {
return nil
}
out := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Interface()
}
return out
}
// FindArrayField finds the primary array field in a response's data object.
// It first checks knownArrayFields in priority order, then falls back to
// the lexicographically smallest unknown array field for deterministic results.
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := arr.([]interface{}); isArr {
if isSliceLike(arr) {
return name
}
}
@@ -31,7 +58,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 isSliceLike(v) {
candidates = append(candidates, k)
}
}
@@ -81,7 +108,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 := toGenericSlice(dataObj[field]); items != nil {
return items
}
}
@@ -90,7 +117,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 := toGenericSlice(resultMap[field]); items != nil {
return items
}
}

View File

@@ -266,6 +266,129 @@ func TestExtractItems(t *testing.T) {
}
}
// --- Typed-slice regression tests ---
// These cover the scenario where shortcut code uses []map[string]interface{}
// (or other typed slices) instead of []interface{} in outData.
func TestExtractItems_TypedMapSlice(t *testing.T) {
// Simulates shortcut pattern: outData["chats"] = []map[string]interface{}{...}
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Test Chat"},
{"chat_id": "oc_def", "name": "Dev Chat"},
},
"total": 2,
"has_more": false,
}
items := ExtractItems(data)
if len(items) != 2 {
t.Fatalf("expected 2 items from typed map slice, got %d", len(items))
}
// Verify elements are still map[string]interface{} (flattenItem can handle them)
for i, item := range items {
if _, ok := item.(map[string]interface{}); !ok {
t.Errorf("item[%d] should be map[string]interface{}, got %T", i, item)
}
}
}
func TestExtractItems_TypedMapSlice_InEnvelope(t *testing.T) {
// Typed slice inside a Lark API envelope: result["data"]["items"] = []map[string]interface{}{...}
data := map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"id": "1", "name": "Alice"},
},
"has_more": false,
},
}
items := ExtractItems(data)
if len(items) != 1 {
t.Fatalf("expected 1 item from typed slice in envelope, got %d", len(items))
}
}
func TestFormatValue_Table_TypedMapSlice(t *testing.T) {
// The core bug: --format table with []map[string]interface{} should render
// multi-column table, not a key-value two-column fallback.
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Lark Dev"},
},
"total": 1,
"has_more": false,
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
// Should have column headers from the data fields
if !strings.Contains(out, "chat_id") {
t.Errorf("table should contain 'chat_id' column header, got:\n%s", out)
}
if !strings.Contains(out, "name") {
t.Errorf("table should contain 'name' column header, got:\n%s", out)
}
if !strings.Contains(out, "Lark Dev") {
t.Errorf("table should contain data value 'Lark Dev', got:\n%s", out)
}
// Should NOT render as key-value fallback (metadata as rows)
if strings.Contains(out, "has_more") {
t.Errorf("table should not contain metadata 'has_more' as a row, got:\n%s", out)
}
}
func TestFormatValue_CSV_TypedMapSlice(t *testing.T) {
data := map[string]interface{}{
"messages": []map[string]interface{}{
{"message_id": "om_abc", "content": "hello"},
{"message_id": "om_def", "content": "world"},
},
"total": 2,
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatCSV)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("CSV should have header + 2 rows, got %d lines:\n%s", len(lines), buf.String())
}
// Header should contain data field names, not top-level map keys
header := lines[0]
if !strings.Contains(header, "message_id") {
t.Errorf("CSV header should contain 'message_id', got: %s", header)
}
}
func TestFormatValue_NDJSON_TypedMapSlice(t *testing.T) {
data := map[string]interface{}{
"tasks": []map[string]interface{}{
{"guid": "t1", "url": "https://example.com/t1"},
{"guid": "t2", "url": "https://example.com/t2"},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatNDJSON)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
}
for i, line := range lines {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Errorf("NDJSON line %d should be valid JSON: %s", i, line)
}
if _, ok := obj["guid"]; !ok {
t.Errorf("NDJSON line %d should contain 'guid' field, got: %s", i, line)
}
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{

View File

@@ -63,9 +63,5 @@
"wiki": {
"en": { "title": "Wiki", "description": "Wiki space and node management" },
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
},
"okr": {
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
}
}

View File

@@ -146,20 +146,11 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
}
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -168,7 +159,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import "testing"
func TestTruncateStr(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate", "hello world", 5, "hello"},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"CJK characters", "你好世界测试", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStr(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}
func TestTruncateStrWithEllipsis(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate with ellipsis", "hello world", 8, "hello..."},
{"limit less than 3", "hello", 2, "he"},
{"limit equals 3", "hello world", 3, "..."},
{"empty", "", 5, ""},
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}

84
package-lock.json generated
View File

@@ -1,84 +0,0 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"cpu": [
"x64",
"arm64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
},
"bin": {
"lark-cli": "scripts/run.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@clack/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.2.0",
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/fast-string-truncated-width": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^1.2.0"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^1.1.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.14",
"version": "1.0.10",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -27,11 +27,7 @@
"license": "MIT",
"files": [
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"CHANGELOG.md"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
}
]
}

View File

@@ -1,372 +0,0 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------
const messages = {
zh: {
setup: "正在设置 Feishu/Lark CLI...",
step1: "正在安装 %s...",
step1Upgrade: "正在升级 %s (v%s → v%s)...",
step1Skip: "已安装 (v%s),跳过",
step1Done: "已全局安装",
step1Upgraded: "已升级到 v%s",
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
step2: "安装 AI Skills",
step2Skip: "已安装,跳过",
step2Spinner: "正在安装 Skills...",
step2Done: "Skills 已安装",
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
step3: "正在配置应用...",
step3NotFound: "未找到 lark-cli终止",
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
step3Skip: "跳过应用配置",
step3Done: "应用已配置",
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
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);
});

View File

@@ -3,10 +3,10 @@
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const { execSync } = require("child_process");
const os = require("os");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
const NAME = "lark-cli";
@@ -43,16 +43,13 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
const args = [
"--fail", "--location", "--silent", "--show-error",
"--connect-timeout", "10", "--max-time", "120",
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
execSync(
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
{ stdio: ["ignore", "ignore", "pipe"] }
);
}
function install() {
@@ -67,12 +64,12 @@ function install() {
}
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
execSync(
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
{ stdio: "ignore" }
);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
stdio: "ignore",
});
}
@@ -88,16 +85,6 @@ function install() {
}
}
// When triggered as a postinstall hook under npx, skip the binary download.
// The "install" wizard doesn't need it, and run.js calls install.js directly
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
const isNpxPostinstall =
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
if (isNpxPostinstall) {
process.exit(0);
}
try {
install();
} catch (err) {

View File

@@ -41,32 +41,21 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
}
}
// Intercept "install" subcommand — run the setup wizard directly,
// bypassing the native binary (which may not exist yet under npx).
const args = process.argv.slice(2);
if (args[0] === "install") {
require("./install-wizard.js");
} else {
// Auto-download binary if missing (e.g. npx skipped postinstall).
if (!fs.existsSync(bin)) {
try {
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
stdio: "inherit",
env: { ...process.env, LARK_CLI_RUN: "true" },
});
} catch (_) {
console.error(
`\nFailed to auto-install lark-cli binary.\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
}
try {
execFileSync(bin, args, { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}
if (!fs.existsSync(bin)) {
console.error(
`Error: lark-cli binary not found at ${bin}\n\n` +
`This usually means the postinstall script was skipped.\n` +
`Common causes:\n` +
` - npm is configured with ignore-scripts=true\n` +
` - The postinstall download failed\n\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
try {
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}

View File

@@ -14,8 +14,7 @@ var BaseBaseCopy = common.Shortcut{
Command: "+base-copy",
Description: "Copy a base resource",
Risk: "write",
UserScopes: []string{"base:app:copy"},
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
Scopes: []string{"base:app:copy"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),

View File

@@ -14,8 +14,7 @@ var BaseBaseCreate = common.Shortcut{
Command: "+base-create",
Description: "Create a new base resource",
Risk: "write",
UserScopes: []string{"base:app:create"},
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
Scopes: []string{"base:app:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
{Name: "name", Desc: "base name", Required: true},

View File

@@ -6,7 +6,6 @@ package base
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -20,16 +19,12 @@ import (
)
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
}
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
config := &core.CliConfig{
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
UserOpenId: "ou_testuser",
}
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
return factory, stdout, reg
@@ -53,14 +48,7 @@ func withBaseWorkingDir(t *testing.T, dir string) {
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
}
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
if authTypes != nil {
shortcut.AuthTypes = authTypes
}
shortcut.AuthTypes = []string{"bot"}
parent := &cobra.Command{Use: "base"}
shortcut.Mount(parent, factory)
parent.SetArgs(args)
@@ -72,14 +60,6 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
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",
@@ -88,32 +68,11 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
},
})
reg.Register(permStub)
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
base, _ := data["base"].(map[string]interface{})
if got := common.GetString(base, "app_token"); got != "app_x" {
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
}
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
body := decodeCapturedJSONBody(t, permStub)
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
t.Fatalf("stdout=%s", got)
}
}
@@ -138,14 +97,6 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
t.Run("copy", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_src/copy",
@@ -154,243 +105,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
},
})
reg.Register(permStub)
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["copied"] != true {
t.Fatalf("copied = %#v, want true", data["copied"])
}
base, _ := data["base"].(map[string]interface{})
if got := common.GetString(base, "base_token"); got != "app_new" {
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
}
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
body := decodeCapturedJSONBody(t, permStub)
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
})
}
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
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 (可管理权限)") {
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("copy bot", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
t.Run("create user", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
}
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
}
return body
}
func TestBaseHistoryExecute(t *testing.T) {
@@ -429,87 +151,6 @@ 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{
@@ -618,7 +259,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", `{"group_config":[{"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", `[{"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"`) {
@@ -636,7 +277,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", `{"sort_config":[{"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", `[{"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"`) {
@@ -1562,7 +1203,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
factory,
stdout,
)
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
})

View File

@@ -17,58 +17,6 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
}
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/copy").
Body(buildBaseCopyBody(runtime)).
Set("base_token", runtime.Str("base-token"))
if runtime.IsBot() {
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
return d
}
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases").
Body(buildBaseCreateBody(runtime))
if runtime.IsBot() {
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
return d
}
func executeBaseGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"base": data}, nil)
return nil
}
func executeBaseCopy(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
if err != nil {
return err
}
out := map[string]interface{}{"base": data, "copied": true}
augmentBasePermissionGrant(runtime, out, data)
runtime.Out(out, nil)
return nil
}
func executeBaseCreate(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
if err != nil {
return err
}
out := map[string]interface{}{"base": data, "created": true}
augmentBasePermissionGrant(runtime, out, data)
runtime.Out(out, nil)
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
@@ -82,10 +30,13 @@ func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
body["time_zone"] = timeZone
}
return body
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/copy").
Body(body).
Set("base_token", runtime.Str("base-token"))
}
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{"name": runtime.Str("name")}
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
body["folder_token"] = folderToken
@@ -93,20 +44,54 @@ func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{}
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
body["time_zone"] = timeZone
}
return body
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases").
Body(body)
}
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
out["permission_grant"] = grant
func executeBaseGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"base": data}, nil)
return nil
}
func extractBasePermissionToken(base map[string]interface{}) string {
for _, key := range []string{"base_token", "app_token"} {
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
return token
}
func executeBaseCopy(runtime *common.RuntimeContext) error {
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
}
return ""
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
body["folder_token"] = folderToken
}
if runtime.Bool("without-content") {
body["without_content"] = true
}
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
body["time_zone"] = timeZone
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
return nil
}
func executeBaseCreate(runtime *common.RuntimeContext) error {
body := map[string]interface{}{"name": runtime.Str("name")}
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
body["folder_token"] = folderToken
}
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
body["time_zone"] = timeZone
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
return nil
}

View File

@@ -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 valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
}
}
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate != nil {
t.Fatalf("expected no validate hook, got non-nil")
}
}
@@ -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 || !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": "{"}, 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": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
@@ -255,29 +255,22 @@ 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 reject invalid JSON before dry-run")
if BaseRecordSearch.Validate != nil {
t.Fatalf("record search validate should be nil for API passthrough")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
if BaseRecordUpsert.Validate != nil {
t.Fatalf("record upsert validate should be nil for API passthrough")
}
}
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 := 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)
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)
}
}

View File

@@ -81,7 +81,16 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
return parseJSONObject(pc, runtime.Str("json"), "json")
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
}
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {

View File

@@ -6,7 +6,6 @@ package base
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -37,14 +36,7 @@ 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 {
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 nil, formatJSONError(flagName, "object", err)
}
return result, nil
}

View File

@@ -38,10 +38,7 @@ 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(), "--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") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
@@ -66,7 +63,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 valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -284,11 +281,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 valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{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") {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -25,9 +25,6 @@ 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)

View File

@@ -25,9 +25,6 @@ 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)

View File

@@ -113,9 +113,7 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
}
func validateRecordJSON(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
return nil
}
func recordListFields(runtime *common.RuntimeContext) []string {

View File

@@ -25,9 +25,6 @@ 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)

View File

@@ -26,9 +26,6 @@ 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)

View File

@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
}
func validateViewCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseObjectList(pc, runtime.Str("json"), "json")
return err
return nil
}
func validateViewJSONObject(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
return nil
}
func validateViewJSONValue(runtime *common.RuntimeContext) error {
return nil
}
func executeViewList(runtime *common.RuntimeContext) error {

View File

@@ -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 validateViewJSONObject(runtime)
return validateViewJSONValue(runtime)
},
DryRun: dryRunViewSetGroup,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -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 validateViewJSONObject(runtime)
return validateViewJSONValue(runtime)
},
DryRun: dryRunViewSetSort,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -26,9 +26,6 @@ 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)

View File

@@ -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 APIs that must always be called
// with TAT even when the surrounding shortcut runs as user.
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
// that must be called with TAT even when the surrounding shortcut runs as user.
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {

View File

@@ -20,18 +20,6 @@ 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",
@@ -45,7 +33,6 @@ 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"))
@@ -55,14 +42,6 @@ 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 {
@@ -76,10 +55,9 @@ 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, fileViewType)
createBlockData := buildCreateBlockData(mediaType, 0)
createBlockData["index"] = "<children_len>"
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
@@ -114,7 +92,6 @@ 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 {
@@ -155,7 +132,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, fileViewType))
nil, buildCreateBlockData(mediaType, insertIndex))
if err != nil {
return err
}
@@ -231,22 +208,12 @@ func parentTypeForMediaType(mediaType string) string {
return "docx_image"
}
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
func buildCreateBlockData(mediaType string, index int) map[string]interface{} {
child := map[string]interface{}{
"block_type": blockTypeForMediaType(mediaType),
}
if mediaType == "file" {
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
child["file"] = map[string]interface{}{}
} else {
child["image"] = map[string]interface{}{}
}

View File

@@ -4,20 +4,14 @@
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, 0)
got := buildCreateBlockData("image", 3)
want := map[string]interface{}{
"children": []interface{}{
map[string]interface{}{
@@ -35,7 +29,7 @@ func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
t.Parallel()
got := buildCreateBlockData("file", 1, 0)
got := buildCreateBlockData("file", 1)
want := map[string]interface{}{
"children": []interface{}{
map[string]interface{}{
@@ -50,113 +44,6 @@ 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()
@@ -274,98 +161,3 @@ 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)
}
})
}
}

View File

@@ -28,8 +28,6 @@ var DocsFetch = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
@@ -48,8 +46,6 @@ var DocsFetch = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"unicode/utf8"
@@ -64,7 +63,7 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
Risk: "write",
Scopes: []string{
"docx:document:readonly",
@@ -73,15 +72,14 @@ var DriveAddComment = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{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: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
{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: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
docRef, err := parseCommentDocRef(runtime.Str("doc"))
if err != nil {
return err
}
@@ -90,21 +88,6 @@ 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 != "" {
@@ -116,69 +99,37 @@ 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 and sheet; old doc format only supports full comments")
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
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")
blockID := strings.TrimSpace(runtime.Str("block-id"))
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(resolvedKind, "", replyElements, nil)
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
if mode == commentModeLocal {
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
}
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
dry := common.NewDryRunAPI()
switch {
case mode == commentModeFull && isWiki:
case mode == commentModeFull && resolvedBy == "wiki":
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
case mode == commentModeFull:
dry.Desc("1-step request: create full comment")
case isWiki && strings.TrimSpace(selection) != "":
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
case isWiki:
case resolvedBy == "wiki":
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
case strings.TrimSpace(selection) != "":
dry.Desc("2-step orchestration: locate block -> create local comment")
@@ -186,17 +137,19 @@ 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 isWiki {
if resolvedBy == "wiki" {
step = "[2]"
}
docID := resolvedToken
if isWiki && resolvedToken == docRef.Token {
docID = "<resolved_docx_token>"
}
mcpArgs := map[string]interface{}{
"doc_id": docID,
"doc_id": dryRunLocateDocRef(docRef),
"limit": defaultLocateDocLimit,
"selection_with_ellipsis": selection,
}
@@ -218,29 +171,23 @@ var DriveAddComment = common.Shortcut{
if mode == commentModeLocal {
createDesc = "Create local comment"
step = "[2]"
if isWiki && strings.TrimSpace(selection) != "" {
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
step = "[3]"
} else if isWiki || strings.TrimSpace(selection) != "" {
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
step = "[2]"
} else {
step = "[1]"
}
} else if isWiki {
} else if resolvedBy == "wiki" {
step = "[2]"
}
return dry.POST(createPath).
Desc(step+" "+createDesc).
Body(commentBody).
Set("file_token", resolvedToken)
Set("file_token", targetToken)
},
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)
@@ -250,11 +197,6 @@ 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
@@ -283,9 +225,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, nil)
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
if mode == commentModeLocal {
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
}
if mode == commentModeLocal {
@@ -346,7 +288,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
return commentModeLocal
}
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
func parseCommentDocRef(input string) (commentDocRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
@@ -355,9 +297,6 @@ func parseCommentDocRef(input, docType 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
}
@@ -365,29 +304,40 @@ func parseCommentDocRef(input, docType 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/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", 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)
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
}
// 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: "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"
}
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, runtime.Str("type"))
docRef, err := parseCommentDocRef(input)
if err != nil {
return resolvedCommentTarget{}, err
}
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")
if docRef.Kind == "docx" || docRef.Kind == "doc" {
if mode == commentModeLocal && docRef.Kind != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
}
return resolvedCommentTarget{
DocID: docRef.Token,
@@ -414,22 +364,11 @@ 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 only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -592,24 +531,12 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
return replyElements, nil
}
type sheetAnchor struct {
SheetID string
Col int
Row int
}
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
body := map[string]interface{}{
"file_type": fileType,
"reply_elements": replyElements,
}
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) != "" {
if strings.TrimSpace(blockID) != "" {
body["anchor"] = map[string]interface{}{
"block_id": blockID,
}
@@ -624,6 +551,13 @@ 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) != "" {
@@ -642,83 +576,6 @@ 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 {

View File

@@ -6,9 +6,6 @@ package drive
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestParseCommentDocRef(t *testing.T) {
@@ -17,7 +14,6 @@ func TestParseCommentDocRef(t *testing.T) {
tests := []struct {
name string
input string
docType string
wantKind string
wantToken string
wantErr string
@@ -35,31 +31,11 @@ func TestParseCommentDocRef(t *testing.T) {
wantToken: "xxxxxx",
},
{
name: "raw token with type docx",
name: "raw token treated as 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",
@@ -77,7 +53,7 @@ func TestParseCommentDocRef(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseCommentDocRef(tt.input, tt.docType)
got, err := parseCommentDocRef(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
@@ -273,7 +249,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
"text": "全文评论",
},
}
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
got := buildCommentCreateV2Request("docx", "", replyElements)
if got["file_type"] != "docx" {
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
@@ -303,7 +279,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
"text": "评论内容",
},
}
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
if got["file_type"] != "docx" {
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
@@ -324,540 +300,3 @@ 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")
}
}

View File

@@ -1,120 +0,0 @@
// 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
}

View File

@@ -1,266 +0,0 @@
// 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())
}
}

View File

@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,

View File

@@ -12,7 +12,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+upload",
"+create-folder",
"+create-shortcut",
"+download",
"+add-comment",

View File

@@ -74,7 +74,6 @@ var commonEventTypes = []string{
"approval.approval.updated",
"application.application.visibility.added_v6",
"task.task.update_tenant_v1",
"task.task.update_user_access_v2",
"task.task.comment_updated_v1",
"drive.notice.comment_add_v1",
}

View File

@@ -602,51 +602,3 @@ func TestMediaBufferReader(t *testing.T) {
}
}
}
func TestMediaBufferFileName(t *testing.T) {
tests := []struct {
label string
buf mediaBuffer
want string
}{
{"original URL filename", mediaBuffer{name: "report.pdf", ext: ".pdf"}, "report.pdf"},
{"name with spaces", mediaBuffer{name: "Q1 report.pdf", ext: ".pdf"}, "Q1 report.pdf"},
{"download fallback", mediaBuffer{name: "download", ext: ""}, "download"},
{"ext not leaked into name", mediaBuffer{name: "x", ext: ".mp4"}, "x"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
if got := tt.buf.FileName(); got != tt.want {
t.Fatalf("FileName() = %q, want %q", got, tt.want)
}
})
}
}
// TestNewMediaBufferFromBytesURLFilename locks in the URL -> mediaBuffer.name
// wiring so a future refactor cannot regress back to the "media.<ext>" synthetic
// filename that was shipped in 91067ec.
func TestNewMediaBufferFromBytesURLFilename(t *testing.T) {
tests := []struct {
label string
url string
want string
}{
{"path filename", "http://example.com/report.pdf", "report.pdf"},
{"filename survives query string", "http://example.com/videos/clip.mp4?token=abc", "clip.mp4"},
{"percent-encoded spaces decoded", "http://example.com/Q1%20report.pdf", "Q1 report.pdf"},
{"no path falls back to download", "http://example.com/", "download"},
{"non-http scheme falls back to download", "ftp://example.com/x.pdf", "download"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
mb := newMediaBufferFromBytes([]byte("payload"), ".pdf", tt.url)
if got := mb.FileName(); got != tt.want {
t.Fatalf("FileName() for %q = %q, want %q", tt.url, got, tt.want)
}
if got := mb.FileName(); strings.HasPrefix(got, "media") && tt.want != "media" {
t.Fatalf("regression: FileName() returned synthetic %q for %q", got, tt.url)
}
})
}
}

View File

@@ -584,7 +584,6 @@ func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType strin
type mediaBuffer struct {
data []byte
ext string // file extension including leading dot, e.g. ".mp4"
name string // original file name extracted from the source URL
}
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
@@ -599,14 +598,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
return newMediaBufferFromBytes(data, ext, rawURL), nil
}
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
// without going through the hardened download transport.
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
return &mediaBuffer{data: data, ext: ext}, nil
}
// Reader returns a new io.Reader over the buffered data. Each call returns a
@@ -616,9 +608,9 @@ func (b *mediaBuffer) Reader() io.Reader {
return bytes.NewReader(b.data)
}
// FileName returns the original file name extracted from the source URL.
// FileName returns a synthetic file name based on the URL extension.
func (b *mediaBuffer) FileName() string {
return b.name
return "media" + b.ext
}
// FileType returns the IM file type detected from the extension.
@@ -1139,7 +1131,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
fd.AddField("image_type", imageType)
fd.AddFile("image", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1180,7 +1172,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,
@@ -1208,7 +1200,7 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
fd.AddField("image_type", imageType)
fd.AddFile("image", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1240,7 +1232,7 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
}
fd.AddFile("file", r)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,

View File

@@ -892,38 +892,3 @@ func TestResolveLocalMediaFile(t *testing.T) {
t.Fatalf("resolveLocalMedia(file) = %q, want %q", got, "file_via_resolve")
}
}
// TestUploadFileToIMPreservesLocalFileName locks in that local uploads keep
// the basename of the caller-supplied path as the multipart file_name, so the
// URL-side fix for mediaBuffer cannot silently regress the local branch later.
func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
var gotBody string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/files") {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
gotBody = string(body)
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_key": "file_uploaded"},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmdutil.TestChdir(t, t.TempDir())
localName := "Q1-meeting-notes.pdf"
if err := os.WriteFile(localName, []byte("pdfdata"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
t.Fatalf("uploadFileToIM() error = %v", err)
}
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
t.Fatalf("upload body missing local filename %q; got: %q", localName, gotBody)
}
}

View File

@@ -144,8 +144,6 @@ type DraftProjection struct {
BodyText string `json:"body_text,omitempty"`
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
HasSignature bool `json:"has_signature,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
@@ -184,22 +182,6 @@ type PatchOp struct {
FileName string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
// fetching and interpolating the signature. The patch layer uses this
// pre-rendered content for insert_signature ops.
RenderedSignatureHTML string `json:"-"`
SignatureImages []SignatureImage `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
// Populated by the shortcut layer, consumed by the patch layer.
type SignatureImage struct {
CID string
ContentType string
FileName string
Data []byte
}
func (p Patch) Validate() error {
@@ -292,12 +274,6 @@ func (op PatchOp) Validate() error {
if !op.Target.hasKey() {
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
}
case "insert_signature":
if strings.TrimSpace(op.SignatureID) == "" {
return fmt.Errorf("insert_signature requires signature_id")
}
case "remove_signature":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}

View File

@@ -33,12 +33,10 @@ var protectedHeaders = map[string]bool{
// bodyChangingOps lists patch operations that modify the HTML body content,
// which is the trigger for running local image path resolution.
var bodyChangingOps = map[string]bool{
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
"insert_signature": true,
"remove_signature": true,
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
}
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
@@ -123,10 +121,6 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return fmt.Errorf("remove_inline: %w", err)
}
return removeInline(snapshot, partID)
case "insert_signature":
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}
@@ -290,7 +284,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e
if htmlPart == nil {
return setBody(snapshot, value, options)
}
_, quotePart := SplitAtQuote(string(htmlPart.Body))
_, quotePart := splitAtQuote(string(htmlPart.Body))
if quotePart == "" {
// No quote block found — fall back to regular set_body.
return setBody(snapshot, value, options)
@@ -1141,166 +1135,3 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
removeOrphanedInlineParts(snapshot.Body, refSet)
return nil
}
// ── Signature patch operations ──
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
// The RenderedSignatureHTML and SignatureImages fields must be populated
// by the shortcut layer before calling Apply.
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
}
html := string(htmlPart.Body)
// Collect CIDs from old signature before removing it, so we can
// clean up orphaned MIME inline parts and avoid duplicates.
oldSigCIDs := collectSignatureCIDsFromHTML(html)
// Remove existing signature (if any), including preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned MIME inline parts from old signature.
for _, cid := range oldSigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
// Split at quote and insert signature between body and quote.
body, quote := SplitAtQuote(html)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
html = body + sigBlock + quote
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
// Add signature inline images to the MIME tree.
for _, img := range op.SignatureImages {
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
}
syncTextPartFromHTML(snapshot, html)
return nil
}
// removeSignatureOp removes the signature block from the HTML body.
func removeSignatureOp(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("remove_signature: no HTML body part found")
}
html := string(htmlPart.Body)
if !signatureWrapperRe.MatchString(html) {
return fmt.Errorf("no signature found in draft body")
}
// Collect CIDs referenced by the signature before removing it.
sigCIDs := collectSignatureCIDsFromHTML(html)
// Remove signature and preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
for _, cid := range sigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
syncTextPartFromHTML(snapshot, html)
return nil
}
// syncTextPartFromHTML regenerates the text/plain part from the current HTML,
// mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) {
if snapshot.PrimaryTextPartID == "" {
return
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
return
}
textPart.Body = []byte(plainTextFromHTML(html))
textPart.Dirty = true
}
// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
// RemoveSignatureHTML are exported from projection.go to avoid duplication
// with the mail package's signature_html.go.
// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
func collectSignatureCIDsFromHTML(html string) []string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return nil
}
sigEnd := FindMatchingCloseDiv(html, loc[0])
sigHTML := html[loc[0]:sigEnd]
matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1)
cids := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) >= 2 {
cids = append(cids, m[1])
}
}
return cids
}
// removeMIMEPartByCID removes the first MIME part with the given Content-ID.
func removeMIMEPartByCID(root *Part, cid string) {
if root == nil {
return
}
normalizedCID := strings.Trim(cid, "<>")
for i, child := range root.Children {
if child == nil {
continue
}
childCID := strings.Trim(child.ContentID, "<>")
if strings.EqualFold(childCID, normalizedCID) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
return
}
removeMIMEPartByCID(child, cid)
}
}
// addInlinePartToSnapshot adds an inline image part to the MIME tree.
func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) {
part := &Part{
MediaType: contentType,
ContentDisposition: "inline",
ContentID: strings.Trim(cid, "<>"),
Body: data,
Dirty: true,
}
if filename != "" {
part.MediaParams = map[string]string{"name": filename}
}
// Find or create the multipart/related container.
if snapshot.Body == nil {
return
}
if snapshot.Body.IsMultipart() {
snapshot.Body.Children = append(snapshot.Body.Children, part)
}
// Non-multipart body: inline part is not added. This is expected when
// the draft has a simple text/html body without multipart/related wrapper.
// The signature HTML still references the CID, but the image won't render.
// In practice, compose shortcuts wrap the body in multipart/related when
// inline images are present, so this path rarely triggers.
}
// containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
// case-insensitively. Aligned with other CID comparisons in this package.
func containsCIDIgnoreCase(html, cid string) bool {
return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid))
}

View File

@@ -1,203 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// insert_signature — basic insertion into HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_BasicHTML(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Sig test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-123",
RenderedSignatureHTML: "<div>-- My Signature</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if !strings.Contains(html, "My Signature") {
t.Error("signature not found in HTML body")
}
if !strings.Contains(html, `class="lark-mail-signature"`) {
t.Error("signature wrapper class not found")
}
if !strings.Contains(html, `id="sig-123"`) {
t.Error("signature ID not found")
}
// Body text should come before signature
bodyIdx := strings.Index(html, "Hello")
sigIdx := strings.Index(html, "My Signature")
if bodyIdx > sigIdx {
t.Error("signature should appear after body text")
}
}
// ---------------------------------------------------------------------------
// insert_signature — with quoted content (reply/forward)
// ---------------------------------------------------------------------------
func TestInsertSignature_BeforeQuote(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Reply with sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="lark-mail-quote-cli123" class="history-quote-wrapper"><div>quoted content</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-456",
RenderedSignatureHTML: "<div>-- Reply Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
sigIdx := strings.Index(html, "Reply Sig")
quoteIdx := strings.Index(html, "quoted content")
if sigIdx < 0 || quoteIdx < 0 {
t.Fatalf("missing signature or quote in: %s", html)
}
if sigIdx > quoteIdx {
t.Error("signature should appear before quote block")
}
}
// ---------------------------------------------------------------------------
// insert_signature — replaces existing signature
// ---------------------------------------------------------------------------
func TestInsertSignature_ReplacesExisting(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Replace sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="old-sig" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- Old Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "new-sig",
RenderedSignatureHTML: "<div>-- New Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "Old Sig") {
t.Error("old signature should have been removed")
}
if !strings.Contains(html, "New Sig") {
t.Error("new signature not found")
}
}
// ---------------------------------------------------------------------------
// insert_signature — no HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_NoHTMLBody(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain text
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Just plain text`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-x",
RenderedSignatureHTML: "<div>sig</div>",
}},
})
if err == nil {
t.Fatal("expected error for insert_signature on plain text draft")
}
if !strings.Contains(err.Error(), "no HTML body") {
t.Fatalf("expected 'no HTML body' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_signature — removes existing signature
// ---------------------------------------------------------------------------
func TestRemoveSignature_Basic(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Remove sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="sig-rm" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- My Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err != nil {
t.Fatalf("Apply remove_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "My Sig") {
t.Error("signature should have been removed")
}
if strings.Contains(html, "lark-mail-signature") {
t.Error("signature wrapper should have been removed")
}
if !strings.Contains(html, "Hello") {
t.Error("body text should be preserved")
}
}
// ---------------------------------------------------------------------------
// remove_signature — no signature present
// ---------------------------------------------------------------------------
func TestRemoveSignature_NoSignature(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: No sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>No signature here</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err == nil {
t.Fatal("expected error when removing non-existent signature")
}
if !strings.Contains(err.Error(), "no signature found") {
t.Fatalf("expected 'no signature found' error, got: %v", err)
}
}

View File

@@ -4,7 +4,6 @@
package draft
import (
"html"
"regexp"
"strings"
)
@@ -28,18 +27,6 @@ var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapper
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
// SignatureWrapperClass is the CSS class for the mail signature container.
const SignatureWrapperClass = "lark-mail-signature"
var signatureWrapperRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
// signatureIDRe extracts the id from a signature wrapper div, regardless of
// whether id appears before or after the class attribute.
var signatureIDRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` +
`|<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
func Project(snapshot *DraftSnapshot) DraftProjection {
proj := DraftProjection{
Subject: snapshot.Subject,
@@ -58,17 +45,6 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
html := string(part.Body)
proj.BodyHTMLSummary = summarizeHTML(html)
proj.HasQuotedContent = hasQuotedContent(html)
proj.HasSignature = signatureWrapperRe.MatchString(html)
if proj.HasSignature {
if m := signatureIDRe.FindStringSubmatch(html); m != nil {
// alternation regex: id is in m[1] (class-first) or m[2] (id-first)
if m[1] != "" {
proj.SignatureID = m[1]
} else if len(m) >= 3 {
proj.SignatureID = m[2]
}
}
}
}
parts := flattenParts(snapshot.Body)
@@ -152,10 +128,10 @@ func hasQuotedContent(html string) bool {
return quoteWrapperRe.MatchString(html)
}
// SplitAtQuote splits an HTML body into the user-authored content and
// splitAtQuote splits an HTML body into the user-authored content and
// the trailing reply/forward quote block. If no quote block is found,
// quote is empty and body is the original html unchanged.
func SplitAtQuote(html string) (body, quote string) {
func splitAtQuote(html string) (body, quote string) {
loc := quoteWrapperRe.FindStringIndex(html)
if loc == nil {
return html, ""
@@ -163,70 +139,6 @@ func SplitAtQuote(html string) (body, quote string) {
return html[:loc[0]], html[loc[0]:]
}
// ── Exported signature HTML utilities ──
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
var signatureSpacingRe = regexp.MustCompile(
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
// matching the structure generated by the Lark mail editor.
func SignatureSpacing() string {
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
return line + line
}
// BuildSignatureHTML wraps signature content in the standard signature container div.
// sigID is HTML-escaped to prevent attribute injection.
func BuildSignatureHTML(sigID, content string) string {
return `<div id="` + html.EscapeString(sigID) + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
}
// FindMatchingCloseDiv finds the position after the closing </div> that matches
// the <div at startPos, tracking nesting depth.
func FindMatchingCloseDiv(html string, startPos int) int {
depth := 0
i := startPos
for i < len(html) {
if strings.HasPrefix(html[i:], "<div") {
depth++
i += 4
} else if strings.HasPrefix(html[i:], "</div>") {
depth--
i += 6
if depth == 0 {
return i
}
} else {
i++
}
}
return len(html)
}
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
// Returns the HTML unchanged if no signature is found.
func RemoveSignatureHTML(html string) string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return html
}
sigStart := loc[0]
sigEnd := FindMatchingCloseDiv(html, sigStart)
// Extend backward to include preceding spacing.
beforeSig := html[:sigStart]
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
sigStart = spacingLoc[0]
}
return html[:sigStart] + html[sigEnd:]
}
func summarizeHTML(html string) string {
trimmed := strings.TrimSpace(html)
runes := []rune(trimmed)

View File

@@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteReply(t *testing.T) {
html := `<div>My reply</div><div class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != `<div>My reply</div>` {
t.Fatalf("body = %q", body)
}
@@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) {
func TestSplitAtQuoteForward(t *testing.T) {
html := `<div>note</div><div id="lark-mail-quote-cli123456" class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != `<div>note</div>` {
t.Fatalf("body = %q", body)
}
@@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) {
func TestSplitAtQuoteNoQuote(t *testing.T) {
html := `<div>no quote here</div>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != html {
t.Fatalf("body = %q, want original html", body)
}
@@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
html := `<p>The CSS class history-quote-wrapper is used for quotes.</p>`
body, quote := SplitAtQuote(html)
body, quote := splitAtQuote(html)
if body != html {
t.Fatalf("body should be unchanged, got %q", body)
}

View File

@@ -59,12 +59,8 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
return err
}
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
var bodyParams map[string]interface{}
if sendTime != "" {
bodyParams = map[string]interface{}{"send_time": sendTime}
}
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) {
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil)
}
func extractDraftID(data map[string]interface{}) string {

View File

@@ -17,7 +17,6 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
@@ -1163,7 +1162,6 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
out["date_formatted"] = normalized.DateFormatted
out["message_state_text"] = normalized.MessageStateText
if normalized.PriorityType != "" {
out["priority_type"] = normalized.PriorityType
out["priority_type_text"] = normalized.PriorityTypeText
}
out["body_plain_text"] = normalized.BodyPlainText
@@ -1242,22 +1240,11 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
out.MessageStateText = messageStateText(state)
out.FolderID = strVal(msg["folder_id"])
out.LabelIDs = toStringList(msg["label_ids"])
// Priority: prefer label_ids (HIGH_PRIORITY/LOW_PRIORITY), fall back to priority_type field.
priorityType := strVal(msg["priority_type"])
out.PriorityType = priorityType
if priorityType != "" {
out.PriorityTypeText = priorityTypeText(priorityType)
}
for _, label := range out.LabelIDs {
switch label {
case "HIGH_PRIORITY":
out.PriorityType = "1"
out.PriorityTypeText = "high"
case "LOW_PRIORITY":
out.PriorityType = "5"
out.PriorityTypeText = "low"
}
}
if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil {
out.SecurityLevel = securityLevel
}
@@ -1720,48 +1707,6 @@ func priorityTypeText(priorityType string) string {
}
}
// priorityFlag is the common flag definition for --priority, shared by all compose shortcuts.
var priorityFlag = common.Flag{
Name: "priority",
Desc: "Email priority: high, normal, low. If omitted, no priority header is set.",
}
// parsePriority parses the --priority flag value and returns the X-Cli-Priority
// header value. Returns "" if the priority should not be set (empty or "normal").
func parsePriority(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return "", nil
case "high":
return "1", nil
case "normal":
return "", nil
case "low":
return "5", nil
default:
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
}
}
// validatePriorityFlag validates the --priority flag value in Validate, so invalid
// values are caught before Execute (and before dry-run prints an API plan).
func validatePriorityFlag(runtime *common.RuntimeContext) error {
v := runtime.Str("priority")
if v == "" {
return nil
}
_, err := parsePriority(v)
return err
}
// applyPriority sets the X-Cli-Priority header on the EML builder if priority is non-empty.
func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
if priority == "" {
return bld
}
return bld.Header("X-Cli-Priority", priority)
}
// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
// by email address (case-insensitive), preserving the first occurrence.
@@ -1961,27 +1906,6 @@ func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes
return nil
}
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
func validateSendTime(runtime *common.RuntimeContext) error {
sendTime := runtime.Str("send-time")
if sendTime == "" {
return nil
}
if !runtime.Bool("confirm-send") {
return fmt.Errorf("--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
// validateConfirmSendScope checks that the user's token includes the
// mail:user_mailbox.message:send scope when --confirm-send is set.
// This scope is not declared in the shortcut's static Scopes (to keep the
@@ -2009,23 +1933,6 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
return nil
}
// buildSendResult builds the output map for a successful send, including
// recall tip if the backend indicates the message is recallable.
func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} {
result := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
result["recall_available"] = true
result["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
return result
}
// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and

View File

@@ -13,10 +13,8 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -1008,212 +1006,3 @@ func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
t.Fatalf("unexpected error message: %v", err)
}
}
// ---------------------------------------------------------------------------
// validateSendTime
// ---------------------------------------------------------------------------
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
cmd.Flags().Bool("confirm-send", false, "")
if sendTime != "" {
_ = cmd.Flags().Set("send-time", sendTime)
}
if confirmSend {
_ = cmd.Flags().Set("confirm-send", "true")
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestValidateSendTime_Empty(t *testing.T) {
rt := newSendTimeRuntime(t, "", false)
if err := validateSendTime(rt); err != nil {
t.Fatalf("expected nil when send-time is empty, got %v", err)
}
}
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
rt := newSendTimeRuntime(t, future, false)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is set without --confirm-send")
}
if !strings.Contains(err.Error(), "--confirm-send") {
t.Errorf("expected error to mention --confirm-send, got: %v", err)
}
}
func TestValidateSendTime_InvalidInteger(t *testing.T) {
rt := newSendTimeRuntime(t, "not-a-number", true)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is not a valid integer")
}
if !strings.Contains(err.Error(), "Unix timestamp") {
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
}
}
func TestValidateSendTime_TooSoon(t *testing.T) {
// Just 1 minute in the future — below the 5-minute minimum.
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
rt := newSendTimeRuntime(t, soon, true)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
}
}
func TestValidateSendTime_Valid(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
rt := newSendTimeRuntime(t, future, true)
if err := validateSendTime(rt); err != nil {
t.Fatalf("expected nil for valid future send-time, got %v", err)
}
}
func TestParsePriority(t *testing.T) {
cases := []struct {
name string
input string
want string
wantErr bool
}{
{"empty", "", "", false},
{"high", "high", "1", false},
{"normal", "normal", "", false},
{"low", "low", "5", false},
{"case-insensitive HIGH", "HIGH", "1", false},
{"whitespace padding", " low ", "5", false},
{"invalid", "urgent", "", true},
{"numeric not accepted", "1", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parsePriority(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
cases := []struct {
name string
labels []interface{}
priorityType string
wantType string
wantText string
}{
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
msg := map[string]interface{}{
"message_id": "m1",
"label_ids": tc.labels,
}
if tc.priorityType != "" {
msg["priority_type"] = tc.priorityType
}
out := buildMessageOutput(msg, false)
gotText, _ := out["priority_type_text"].(string)
if gotText != tc.wantText {
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
}
gotType, _ := out["priority_type"].(string)
if gotType != tc.wantType {
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
}
})
}
}
func TestApplyPriority(t *testing.T) {
// Empty priority: EML must not contain X-Cli-Priority header.
emptyBld := emlbuilder.New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("no priority").
TextBody([]byte("body"))
emptyBld = applyPriority(emptyBld, "")
raw, err := emptyBld.BuildBase64URL()
if err != nil {
t.Fatalf("build EML failed: %v", err)
}
eml := decodeBase64URL(raw)
if strings.Contains(eml, "X-Cli-Priority") {
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
}
// Non-empty priority: header must be present with the exact value.
highBld := emlbuilder.New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("high priority").
TextBody([]byte("body"))
highBld = applyPriority(highBld, "1")
raw, err = highBld.BuildBase64URL()
if err != nil {
t.Fatalf("build EML failed: %v", err)
}
eml = decodeBase64URL(raw)
if !strings.Contains(eml, "X-Cli-Priority: 1") {
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
}
}
func TestValidatePriorityFlag(t *testing.T) {
makeRuntime := func(priority string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("priority", "", "")
if priority != "" {
_ = cmd.Flags().Set("priority", priority)
}
return common.TestNewRuntimeContext(cmd, nil)
}
cases := []struct {
name string
priority string
wantErr bool
}{
{"empty ok", "", false},
{"high ok", "high", false},
{"normal ok", "normal", false},
{"low ok", "low", false},
{"invalid urgent", "urgent", true},
{"invalid numeric", "1", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validatePriorityFlag(makeRuntime(tc.priority))
if tc.wantErr && err == nil {
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
}
if !tc.wantErr && err != nil {
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
}
})
}
}

View File

@@ -46,8 +46,6 @@ var MailDraftCreate = common.Shortcut{
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
signatureFlag,
priorityFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
input, err := parseDraftCreateInput(runtime)
@@ -74,32 +72,21 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
return validatePriorityFlag(runtime)
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
input, err := parseDraftCreateInput(runtime)
if err != nil {
return err
}
priority, err := parsePriority(runtime.Str("priority"))
rawEML, err := buildRawEMLForDraftCreate(runtime, input)
if err != nil {
return err
}
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult, priority)
if err != nil {
return err
}
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
@@ -134,7 +121,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
return input, nil
}
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult, priority string) (string, error) {
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) {
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
@@ -166,18 +153,12 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
var autoResolvedPaths []string
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body
if !bodyIsHTML(input.Body) {
htmlBody = buildBodyDiv(input.Body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
} else if bodyIsHTML(input.Body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
if resolveErr != nil {
return "", resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -188,14 +169,12 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}
} else {
bld = bld.TextBody([]byte(input.Body))
}
bld = applyPriority(bld, priority)
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
return "", err

View File

@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
Attach: "./big.txt",
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err == nil {
t.Fatal("expected error for missing CID reference")
}
@@ -142,40 +142,6 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
}
}
func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
input := draftCreateInput{
From: "sender@example.com",
Subject: "priority test",
Body: `<p>Hello</p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "1")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if !strings.Contains(eml, "X-Cli-Priority: 1") {
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
}
}
func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
input := draftCreateInput{
From: "sender@example.com",
Subject: "no priority",
Body: `<p>Hello</p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if strings.Contains(eml, "X-Cli-Priority") {
t.Errorf("expected no X-Cli-Priority header when priority is empty, got:\n%s", eml)
}
}
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
chdirTemp(t)
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
@@ -187,7 +153,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
PlainText: true,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}

View File

@@ -33,7 +33,6 @@ var MailDraftEdit = common.Shortcut{
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -93,24 +92,6 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process insert_signature ops: resolve signature using the draft's
// From address so alias/shared-mailbox senders get correct template vars.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
}
if sigResult != nil {
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
}
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return output.ErrValidation("apply draft patch failed: %v", err)
@@ -277,19 +258,6 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
setRecipients("cc", runtime.Str("set-cc"))
setRecipients("bcc", runtime.Str("set-bcc"))
// --set-priority → inject set_header / remove_header op
if setPriority := runtime.Str("set-priority"); setPriority != "" {
headerVal, pErr := parsePriority(setPriority)
if pErr != nil {
return patch, pErr
}
if headerVal != "" {
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_header", Name: "X-Cli-Priority", Value: headerVal})
} else {
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_header", Name: "X-Cli-Priority"})
}
}
if len(patch.Ops) == 0 {
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
}
@@ -345,8 +313,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"},
},
"supported_ops_by_group": []map[string]interface{}{
{
@@ -382,13 +348,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
},
{
"group": "signature",
"ops": []map[string]interface{}{
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"},
},
},
},
"recommended_usage": []string{
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newDraftEditRuntime creates a minimal RuntimeContext with the draft-edit
// flags used by buildDraftEditPatch.
func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{
"set-subject", "set-to", "set-cc", "set-bcc",
"set-priority", "patch-file",
} {
cmd.Flags().String(name, "", "")
}
for name, val := range flags {
_ = cmd.Flags().Set(name, val)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestBuildDraftEditPatch_SetPriorityHigh(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-priority": "high"})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
}
op := patch.Ops[0]
if op.Op != "set_header" {
t.Errorf("Op = %q, want set_header", op.Op)
}
if op.Name != "X-Cli-Priority" {
t.Errorf("Name = %q, want X-Cli-Priority", op.Name)
}
if op.Value != "1" {
t.Errorf("Value = %q, want 1", op.Value)
}
}
func TestBuildDraftEditPatch_SetPriorityLow(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-priority": "low"})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 || patch.Ops[0].Value != "5" {
t.Fatalf("expected single set_header with value 5, got %+v", patch.Ops)
}
}
func TestBuildDraftEditPatch_SetPriorityNormalClears(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-priority": "normal"})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
}
if patch.Ops[0].Op != "remove_header" || patch.Ops[0].Name != "X-Cli-Priority" {
t.Errorf("expected remove_header X-Cli-Priority, got %+v", patch.Ops[0])
}
}
func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-priority": "urgent"})
if _, err := buildDraftEditPatch(rt); err == nil {
t.Fatal("expected error for invalid --set-priority value")
}
}
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only the set_subject op should be present; no priority op injected.
if len(patch.Ops) != 1 || patch.Ops[0].Op != "set_subject" {
t.Errorf("expected single set_subject op, got %+v", patch.Ops)
}
}

View File

@@ -34,9 +34,7 @@ var MailForward = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
signatureFlag,
priorityFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -61,21 +59,12 @@ var MailForward = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if runtime.Bool("confirm-send") {
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
return err
}
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
return validatePriorityFlag(runtime)
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
@@ -87,19 +76,8 @@ var MailForward = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTime := runtime.Str("send-time")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -136,7 +114,7 @@ var MailForward = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -160,13 +138,8 @@ var MailForward = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + forwardQuote
fullHTML := resolved + forwardQuote
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -177,13 +150,12 @@ var MailForward = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
}
bld = applyPriority(bld, priority)
// Download original attachments and accumulate size for limit check
type downloadedAtt struct {
content []byte
@@ -246,11 +218,14 @@ var MailForward = common.Shortcut{
hintSendDraft(runtime, mailboxID, draftID)
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,9 +32,7 @@ var MailReply = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
signatureFlag,
priorityFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -58,16 +56,7 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
return validatePriorityFlag(runtime)
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
@@ -79,24 +68,13 @@ var MailReply = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTime := runtime.Str("send-time")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -114,7 +92,7 @@ var MailReply = common.Shortcut{
}
replyTo = mergeAddrLists(replyTo, toFlag)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -161,13 +139,8 @@ var MailReply = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -178,13 +151,12 @@ var MailReply = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(bodyStr + quoted))
}
bld = applyPriority(bld, priority)
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
return err
@@ -209,11 +181,14 @@ var MailReply = common.Shortcut{
hintSendDraft(runtime, mailboxID, draftID)
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -33,9 +33,7 @@ var MailReplyAll = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
signatureFlag,
priorityFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -59,16 +57,7 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
return validatePriorityFlag(runtime)
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
@@ -81,24 +70,13 @@ var MailReplyAll = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTime := runtime.Str("send-time")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -132,7 +110,7 @@ var MailReplyAll = common.Shortcut{
return err
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -175,13 +153,8 @@ var MailReplyAll = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -192,13 +165,12 @@ var MailReplyAll = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(bodyStr + quoted))
}
bld = applyPriority(bld, priority)
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
return err
@@ -223,11 +195,14 @@ var MailReplyAll = common.Shortcut{
hintSendDraft(runtime, mailboxID, draftID)
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,9 +32,7 @@ var MailSend = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
signatureFlag,
priorityFlag},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -64,15 +62,6 @@ var MailSend = common.Shortcut{
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validatePriorityFlag(runtime); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -85,20 +74,8 @@ var MailSend = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTime := runtime.Str("send-time")
senderEmail := resolveComposeSenderEmail(runtime)
signatureID := runtime.Str("signature-id")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
}
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
if err != nil {
return err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
Subject(subject).
@@ -119,19 +96,12 @@ var MailSend = common.Shortcut{
var autoResolvedPaths []string
if plainText {
bld = bld.TextBody([]byte(body))
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.
htmlBody := body
if !bodyIsHTML(body) {
htmlBody = buildBodyDiv(body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
} else if bodyIsHTML(body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
if resolveErr != nil {
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -142,14 +112,12 @@ var MailSend = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(body))
}
bld = applyPriority(bld, priority)
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
return err
@@ -164,6 +132,7 @@ var MailSend = common.Shortcut{
return fmt.Errorf("failed to build EML: %w", err)
}
mailboxID := resolveComposeMailboxID(runtime)
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
@@ -176,11 +145,14 @@ var MailSend = common.Shortcut{
hintSendDraft(runtime, mailboxID, draftID)
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(buildSendResult(resData, mailboxID), nil)
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
return nil
},
}

View File

@@ -1,135 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"github.com/zalando/go-keyring"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// mailShortcutTestFactoryWithSendScope mirrors mailShortcutTestFactory but
// additionally grants the mail:user_mailbox.message:send scope so tests can
// exercise code paths guarded by validateConfirmSendScope (e.g. validateSendTime).
func mailShortcutTestFactoryWithSendScope(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
cfg := mailTestConfig()
token := &auth.StoredUAToken{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AccessToken: "test-user-access-token",
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
t.Cleanup(func() {
_ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId)
})
return cmdutil.TestFactory(t, cfg)
}
// tooSoonSendTime returns a send-time 60s in the future — below the 5-minute
// floor enforced by validateSendTime.
func tooSoonSendTime() string {
return strconv.FormatInt(time.Now().Unix()+60, 10)
}
// futureSendTime returns a send-time 10 minutes in the future — above the floor.
func futureSendTime() string {
return strconv.FormatInt(time.Now().Unix()+10*60, 10)
}
// ---------------------------------------------------------------------------
// Invalid --send-time rejected by each compose shortcut
// ---------------------------------------------------------------------------
func TestMailSend_SendTimeTooSoon(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
err := runMountedMailShortcut(t, MailSend, []string{
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
"--confirm-send", "--send-time", tooSoonSendTime(),
}, f, stdout)
if err == nil {
t.Fatal("expected error for too-soon send-time, got nil")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected 5-minute error, got: %v", err)
}
}
func TestMailReply_SendTimeTooSoon(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001", "--body", "hello",
"--confirm-send", "--send-time", tooSoonSendTime(),
}, f, stdout)
if err == nil {
t.Fatal("expected error for too-soon send-time, got nil")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected 5-minute error, got: %v", err)
}
}
func TestMailReplyAll_SendTimeTooSoon(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
err := runMountedMailShortcut(t, MailReplyAll, []string{
"+reply-all", "--message-id", "msg_001", "--body", "hello",
"--confirm-send", "--send-time", tooSoonSendTime(),
}, f, stdout)
if err == nil {
t.Fatal("expected error for too-soon send-time, got nil")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected 5-minute error, got: %v", err)
}
}
func TestMailForward_SendTimeTooSoon(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward", "--message-id", "msg_001", "--to", "alice@example.com",
"--confirm-send", "--send-time", tooSoonSendTime(),
}, f, stdout)
if err == nil {
t.Fatal("expected error for too-soon send-time, got nil")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected 5-minute error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// --send-time without --confirm-send is rejected up front
// ---------------------------------------------------------------------------
func TestMailSend_SendTimeWithoutConfirmSend(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
err := runMountedMailShortcut(t, MailSend, []string{
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
"--send-time", futureSendTime(),
}, f, stdout)
if err == nil {
t.Fatal("expected error for --send-time without --confirm-send, got nil")
}
if !strings.Contains(err.Error(), "--confirm-send") {
t.Errorf("expected error to mention --confirm-send, got: %v", err)
}
}

View File

@@ -1,216 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"regexp"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/signature"
)
var MailSignature = common.Shortcut{
Service: "mail",
Command: "+signature",
Description: "List or view email signatures with default usage info.",
Risk: "read",
Scopes: []string{"mail:user_mailbox:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "from", Default: "me", Desc: "Mailbox address (default: me)"},
{Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
return common.NewDryRunAPI().
Desc("List or view email signatures").
GET(mailboxPath(mailboxID, "signatures"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
detailID := runtime.Str("detail")
resp, err := signature.ListAll(runtime, mailboxID)
if err != nil {
return err
}
if detailID != "" {
return executeSignatureDetail(runtime, resp, detailID, mailboxID)
}
return executeSignatureList(runtime, resp)
},
}
func executeSignatureList(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse) error {
// Build default signature ID maps from usages.
sendDefaults := map[string]bool{}
replyDefaults := map[string]bool{}
for _, usage := range resp.Usages {
if usage.SendMailSignatureID != "" && usage.SendMailSignatureID != "0" {
sendDefaults[usage.SendMailSignatureID] = true
}
if usage.ReplySignatureID != "" && usage.ReplySignatureID != "0" {
replyDefaults[usage.ReplySignatureID] = true
}
}
lang := resolveLang(runtime)
items := make([]map[string]interface{}, 0, len(resp.Signatures))
for _, sig := range resp.Signatures {
item := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
if len(sig.Images) > 0 {
item["images"] = len(sig.Images)
}
// Short content preview (rendered for TENANT).
rendered := signature.InterpolateTemplate(&sig, lang, "", "")
item["content_preview"] = contentPreview(rendered, 200, lang)
if sendDefaults[sig.ID] {
item["is_send_default"] = true
}
if replyDefaults[sig.ID] {
item["is_reply_default"] = true
}
items = append(items, item)
}
runtime.OutFormat(
map[string]interface{}{"signatures": items},
&output.Meta{Count: len(items)},
nil,
)
return nil
}
func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse, sigID, mailboxID string) error {
var sig *signature.Signature
for i := range resp.Signatures {
if resp.Signatures[i].ID == sigID {
sig = &resp.Signatures[i]
break
}
}
if sig == nil {
return output.ErrValidation("signature not found: %s", sigID)
}
lang := resolveLang(runtime)
detail := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
// Usage info.
for _, usage := range resp.Usages {
if usage.SendMailSignatureID == sig.ID {
detail["is_send_default"] = true
}
if usage.ReplySignatureID == sig.ID {
detail["is_reply_default"] = true
}
}
// Images metadata — output the full structure from API.
if len(sig.Images) > 0 {
detail["images"] = sig.Images
}
// Template variables (TENANT signatures): show resolved values.
if sig.HasTemplateVars() {
vars := make(map[string]string, len(sig.UserFields))
for key, field := range sig.UserFields {
vars[key] = field.Resolve(lang)
}
detail["template_vars"] = vars
}
// Rendered content preview.
rendered := signature.InterpolateTemplate(sig, lang, "", "")
detail["content_preview"] = contentPreview(rendered, 200, lang)
runtime.Out(detail, nil)
return nil
}
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
func resolveLang(runtime *common.RuntimeContext) string {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "zh_cn"
}
cfg, err := runtime.Factory.Config()
if err != nil {
return "zh_cn"
}
app := multi.FindApp(cfg.ProfileName)
if app == nil {
return "zh_cn"
}
switch app.Lang {
case "en":
return "en_us"
case "ja":
return "ja_jp"
default:
return "zh_cn"
}
}
// contentPreview converts HTML to a compact plain-text preview.
// <img> tags become a localized image placeholder, all other tags become
// spaces, then consecutive whitespace is collapsed. Result is truncated
// to maxLen runes.
func contentPreview(html string, maxLen int, lang string) string {
placeholder := "[image]"
if strings.HasPrefix(lang, "zh") {
placeholder = "[图片]"
}
imgRe := regexp.MustCompile(`<img[^>]*>`)
s := imgRe.ReplaceAllString(html, placeholder)
// Strip remaining tags, replacing each with a space.
var result strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
result.WriteByte(' ')
case r == '>':
inTag = false
case !inTag:
result.WriteRune(r)
}
}
// Collapse whitespace and trim.
text := strings.Join(strings.Fields(result.String()), " ")
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -19,6 +19,5 @@ func Shortcuts() []common.Shortcut {
MailDraftCreate,
MailDraftEdit,
MailForward,
MailSignature,
}
}

View File

@@ -1,157 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"regexp"
"strings"
)
// variableMetaProps represents the JSON structure in data-variable-meta-props attributes.
type variableMetaProps struct {
ID string `json:"id"`
Type string `json:"type"` // "text" or "image"
DisplayName string `json:"displayName"` // human-readable label
Width string `json:"width"` // image width (for type=image)
Style string `json:"style"` // CSS style
Circle bool `json:"circle"` // circular image
}
// variableSpanRe matches <span data-variable-meta-props='...'> and captures the JSON and inner content.
// Group 1: JSON attribute value (double-quoted), Group 2: (single-quoted), Group 3: inner content.
//
// Limitation: uses regex instead of DOM parsing (Go has no built-in DOMParser like JS).
// If a variable <span> contains nested <span> tags, [\s\S]*? will match to the
// innermost </span>, potentially truncating content. In practice, Lark's signature
// templates do not nest <span> inside variable spans (verified against mail-editor
// source and test data). If this becomes an issue, consider using golang.org/x/net/html.
var variableSpanRe = regexp.MustCompile(
`<span\s+data-variable-meta-props=(?:"([^"]*?)"|'([^']*?)')>([\s\S]*?)</span>`)
// InterpolateTemplate replaces template variables in a TENANT signature's content HTML.
// For USER signatures (no template variables), it returns sig.Content unchanged.
//
// Parameters:
// - sig: the signature object
// - lang: language code for i18n ("zh_cn", "en_us", "ja_jp")
// - senderName: sender display name (overrides B-NAME)
// - senderEmail: sender email address (overrides B-ENTERPRISE-EMAIL)
func InterpolateTemplate(sig *Signature, lang, senderName, senderEmail string) string {
if !sig.HasTemplateVars() {
return sig.Content
}
// Build value map from user_fields with i18n resolution.
valueMap := make(map[string]string, len(sig.UserFields)+2)
for key, field := range sig.UserFields {
valueMap[key] = field.Resolve(lang)
}
// Fixed injections override API values.
if senderName != "" {
valueMap["B-NAME"] = senderName
}
if senderEmail != "" {
valueMap["B-ENTERPRISE-EMAIL"] = senderEmail
}
// Replace each <span data-variable-meta-props='...'> with interpolated content.
result := variableSpanRe.ReplaceAllStringFunc(sig.Content, func(match string) string {
submatches := variableSpanRe.FindStringSubmatch(match)
if submatches == nil {
return match
}
// JSON is in group 1 (double-quoted) or group 2 (single-quoted).
attrJSON := submatches[1]
if attrJSON == "" {
attrJSON = submatches[2]
}
// Unescape HTML entities in the JSON attribute value.
attrJSON = unescapeHTMLEntities(attrJSON)
var meta variableMetaProps
if err := json.Unmarshal([]byte(attrJSON), &meta); err != nil {
return match // preserve original on parse failure
}
val, ok := valueMap[meta.ID]
if !ok {
val = "" // variable not in map, replace with empty
}
switch meta.Type {
case "text":
return interpolateText(val, meta.Style)
case "image":
return interpolateImage(val, meta)
default:
return val
}
})
return result
}
// interpolateText returns the replacement for a text variable.
func interpolateText(val, style string) string {
if val == "" {
return ""
}
// If value looks like a URL, wrap in <a>.
if isURL(val) {
escaped := escapeHTML(val)
return `<a href="` + escaped + `" target="_blank" rel="noopener noreferrer">` + escaped + `</a>`
}
if style != "" {
return `<span style="` + escapeHTML(style) + `">` + escapeHTML(val) + `</span>`
}
return escapeHTML(val)
}
// interpolateImage returns the replacement for an image variable.
func interpolateImage(val string, meta variableMetaProps) string {
if val == "" {
return ""
}
var attrs []string
attrs = append(attrs, `src="`+escapeHTML(val)+`"`)
if meta.Width != "" {
attrs = append(attrs, `width="`+escapeHTML(meta.Width)+`"`)
}
var styles []string
if meta.Style != "" {
styles = append(styles, meta.Style)
}
if meta.Circle {
styles = append(styles, "border-radius: 100%")
}
if len(styles) > 0 {
attrs = append(attrs, `style="`+escapeHTML(strings.Join(styles, ";"))+`"`)
}
return `<img ` + strings.Join(attrs, " ") + `>`
}
func isURL(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func unescapeHTMLEntities(s string) string {
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
return s
}

View File

@@ -1,137 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"strings"
"testing"
)
func TestInterpolateTemplate_UserSignatureUnchanged(t *testing.T) {
sig := &Signature{
Content: "<b>My signature</b>",
SignatureType: SignatureTypeUser,
}
got := InterpolateTemplate(sig, "zh_cn", "Alice", "alice@example.com")
if got != sig.Content {
t.Errorf("USER signature should be unchanged, got %q", got)
}
}
func TestInterpolateTemplate_TenantTextVariables(t *testing.T) {
sig := &Signature{
Content: `姓名:<span data-variable-meta-props='{"id":"B-NAME","type":"text"}'>{text}</span>, 部门:<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME", "B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "张三", I18nVals: map[string]string{"zh_cn": "", "en_us": "Zhang San"}},
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "研发部", "en_us": "R&D"}},
},
}
// zh_cn: B-DEPARTMENT should resolve to "研发部" (from i18n), B-NAME overridden by senderName
got := InterpolateTemplate(sig, "zh_cn", "李四", "lisi@example.com")
if !strings.Contains(got, "李四") {
t.Errorf("expected senderName override for B-NAME, got %q", got)
}
if !strings.Contains(got, "研发部") {
t.Errorf("expected zh_cn i18n value for B-DEPARTMENT, got %q", got)
}
if strings.Contains(got, "{text}") {
t.Errorf("should not contain raw placeholder {text}, got %q", got)
}
if strings.Contains(got, "data-variable-meta-props") {
t.Errorf("should not contain data-variable-meta-props attribute, got %q", got)
}
}
func TestInterpolateTemplate_I18nFallback(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "", "en_us": ""}},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "默认部门") {
t.Errorf("expected fallback to DefaultVal, got %q", got)
}
}
func TestInterpolateTemplate_HTMLEntityEscaping(t *testing.T) {
// Simulate the HTML-entity-escaped attribute format from real API responses.
sig := &Signature{
Content: `<span data-variable-meta-props="{&quot;id&quot;:&quot;B-NAME&quot;,&quot;type&quot;:&quot;text&quot;}">{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "default"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "陈煌", "")
if !strings.Contains(got, "陈煌") {
t.Errorf("expected interpolated name, got %q", got)
}
}
func TestInterpolateTemplate_URLAsText(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-URL","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-URL"},
UserFields: map[string]UserFieldValue{
"B-URL": {DefaultVal: "https://example.com"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "<a href=") {
t.Errorf("expected URL to be wrapped in <a> tag, got %q", got)
}
if !strings.Contains(got, "https://example.com") {
t.Errorf("expected URL in output, got %q", got)
}
}
func TestInterpolateTemplate_ImageVariable(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-LOGO","type":"image","width":"40"}'><img src="cid:old"/></span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-LOGO"},
UserFields: map[string]UserFieldValue{
"B-LOGO": {DefaultVal: "cid:new-logo-cid"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, `src="cid:new-logo-cid"`) {
t.Errorf("expected new image src, got %q", got)
}
if !strings.Contains(got, `width="40"`) {
t.Errorf("expected width attribute, got %q", got)
}
}
func TestUserFieldValue_Resolve(t *testing.T) {
v := UserFieldValue{
DefaultVal: "default",
I18nVals: map[string]string{"zh_cn": "中文", "en_us": "", "ja_jp": "日本語"},
}
if got := v.Resolve("zh_cn"); got != "中文" {
t.Errorf("zh_cn = %q, want 中文", got)
}
if got := v.Resolve("en_us"); got != "default" {
t.Errorf("en_us (empty) should fallback to default, got %q", got)
}
if got := v.Resolve("ja_jp"); got != "日本語" {
t.Errorf("ja_jp = %q, want 日本語", got)
}
if got := v.Resolve("fr_fr"); got != "default" {
t.Errorf("unknown lang should fallback, got %q", got)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
// SignatureType represents the type of a mail signature.
type SignatureType string
const (
SignatureTypeUser SignatureType = "USER"
SignatureTypeTenant SignatureType = "TENANT"
)
// SignatureDevice represents the device platform a signature is designed for.
type SignatureDevice string
const (
DevicePC SignatureDevice = "PC"
DeviceMobile SignatureDevice = "MOBILE"
)
// SignatureImage holds metadata for an inline image embedded in a signature.
type SignatureImage struct {
ImageName string `json:"image_name,omitempty"`
FileKey string `json:"file_key,omitempty"`
CID string `json:"cid,omitempty"`
FileSize string `json:"file_size,omitempty"`
Header string `json:"header,omitempty"`
ImageWidth int32 `json:"image_width,omitempty"`
ImageHeight int32 `json:"image_height,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
// UserFieldValue holds a template variable value with multi-language support.
type UserFieldValue struct {
DefaultVal string `json:"default_val"`
I18nVals map[string]string `json:"i18n_vals"` // keys: "zh_cn", "en_us", "ja_jp"
}
// Resolve returns the localized value for the given language code.
// Falls back to DefaultVal when the language key is missing or empty.
func (v UserFieldValue) Resolve(lang string) string {
if val, ok := v.I18nVals[lang]; ok && val != "" {
return val
}
return v.DefaultVal
}
// Signature represents a single mail signature returned by the API.
type Signature struct {
ID string `json:"id"`
Name string `json:"name"`
SignatureType SignatureType `json:"signature_type"`
SignatureDevice SignatureDevice `json:"signature_device"`
Content string `json:"content"`
Images []SignatureImage `json:"images,omitempty"`
TemplateJSONKeys []string `json:"template_json_keys,omitempty"`
UserFields map[string]UserFieldValue `json:"user_fields,omitempty"`
}
// IsTenant returns true if this is a tenant/corporate signature with template variables.
func (s *Signature) IsTenant() bool {
return s.SignatureType == SignatureTypeTenant
}
// HasTemplateVars returns true if the signature contains template variables that need interpolation.
func (s *Signature) HasTemplateVars() bool {
return len(s.TemplateJSONKeys) > 0
}
// SignatureUsage indicates which signature is used by default for a given email address.
type SignatureUsage struct {
EmailAddress string `json:"email_address"`
SendMailSignatureID string `json:"send_mail_signature_id"`
ReplySignatureID string `json:"reply_signature_id"`
}
// GetSignaturesResponse is the parsed response from the get_signatures API.
type GetSignaturesResponse struct {
Signatures []Signature `json:"signatures"`
Usages []SignatureUsage `json:"usages"`
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"fmt"
"net/url"
"github.com/larksuite/cli/shortcuts/common"
)
// processCache holds per-mailbox cached responses.
// CLI runs one command per process, so a package-level map is sufficient —
// it is naturally scoped to a single Execute lifecycle.
var processCache = map[string]*GetSignaturesResponse{}
func signaturesPath(mailboxID string) string {
return "/open-apis/mail/v1/user_mailboxes/" + url.PathEscape(mailboxID) + "/settings/signatures"
}
// ListAll fetches all signatures and usage info for a mailbox.
// Results are cached per mailboxID within the current Execute lifecycle.
func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesResponse, error) {
if cached, ok := processCache[mailboxID]; ok {
return cached, nil
}
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
if err != nil {
return nil, fmt.Errorf("get signatures: %w", err)
}
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
}
var resp GetSignaturesResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
}
processCache[mailboxID] = &resp
return &resp, nil
}
// List returns all signatures for a mailbox.
func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
return resp.Signatures, nil
}
// Get returns a single signature by ID. Returns an error if not found.
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
for i := range resp.Signatures {
if resp.Signatures[i].ID == signatureID {
return &resp.Signatures[i], nil
}
}
return nil, fmt.Errorf("signature not found: %s", signatureID)
}

View File

@@ -1,245 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/signature"
)
// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts.
var signatureFlag = common.Flag{
Name: "signature-id",
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
RenderedContent string
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// Returns nil if signatureID is empty.
// resolveSignature fetches, interpolates, and downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
if signatureID == "" {
return nil, nil
}
sig, err := signature.Get(runtime, mailboxID, signatureID)
if err != nil {
return nil, err
}
// Resolve sender info for template interpolation.
lang := resolveLang(runtime)
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
var images []draftpkg.SignatureImage
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
return &signatureResult{
ID: sig.ID,
RenderedContent: rendered,
Images: images,
}, nil
}
// injectSignatureIntoBody inserts signature HTML into the body, before the quote block.
// It removes any existing signature first, then places the new signature between
// the user-authored content and the quote block (if any).
// Returns the new full HTML body.
func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
if sig == nil {
return bodyHTML
}
cleaned := draftpkg.RemoveSignatureHTML(bodyHTML)
userContent, quote := draftpkg.SplitAtQuote(cleaned)
sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent)
return userContent + sigBlock + quote
}
// addSignatureImagesToBuilder adds signature inline images to the EML builder.
func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder {
if sig == nil {
return bld
}
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid == "" {
continue
}
bld = bld.AddInline(img.Data, img.ContentType, img.FileName, cid)
}
return bld
}
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
// resolveSenderInfo fetches send_as addresses and returns the name/email
// for signature interpolation. If fromEmail is non-empty, it matches
// that address in the sendable list (for alias/send_as scenarios);
// otherwise falls back to the first (primary) address.
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
if err != nil {
return "", ""
}
addrs, ok := data["sendable_addresses"].([]interface{})
if !ok || len(addrs) == 0 {
return "", ""
}
// If fromEmail is specified, find the matching address.
if fromEmail != "" {
for _, a := range addrs {
m, ok := a.(map[string]interface{})
if !ok {
continue
}
e, _ := m["email_address"].(string)
if strings.EqualFold(e, fromEmail) {
n, _ := m["name"].(string)
return n, e
}
}
}
// Fall back to the first sendable address (primary).
first, ok := addrs[0].(map[string]interface{})
if !ok {
return "", ""
}
n, _ := first["name"].(string)
e, _ := first["email_address"].(string)
return n, e
}
// downloadSignatureImage downloads a signature image by its direct URL.
// Security: enforces https, does not send Bearer token (URL is pre-signed),
// uses context timeout, and limits response size. Aligned with
// downloadAttachmentContent in helpers.go.
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
}
if u.Scheme != "https" {
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, "", fmt.Errorf("signature image download: URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
// Do NOT send Authorization: the download URL is pre-signed.
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
}
const maxSize = 10 * 1024 * 1024
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
}
if len(data) > maxSize {
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
}
ct := resp.Header.Get("Content-Type")
if ct == "" || ct == "application/octet-stream" {
ct = contentTypeFromFilename(filename)
}
return data, ct, nil
}
func contentTypeFromFilename(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".bmp":
return "image/bmp"
default:
return "application/octet-stream"
}
}
// signatureCIDs returns the CID list from a signatureResult, for inline CID validation.
func signatureCIDs(sig *signatureResult) []string {
if sig == nil {
return nil
}
cids := make([]string, 0, len(sig.Images))
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid != "" {
cids = append(cids, cid)
}
}
return cids
}
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
// RespAlignment 对齐关系
type RespAlignment struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
FromOwner RespOwner `json:"from_owner"`
ToOwner RespOwner `json:"to_owner"`
FromEntityType string `json:"from_entity_type"`
FromEntityID string `json:"from_entity_id"`
ToEntityType string `json:"to_entity_type"`
ToEntityID string `json:"to_entity_id"`
}
// RespCategory 分类
type RespCategory struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
CategoryType string `json:"category_type"`
Enabled *bool `json:"enabled,omitempty"`
Color *string `json:"color,omitempty"`
Name CategoryName `json:"name"`
}
// RespCycle 周期
type RespCycle struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
TenantCycleID string `json:"tenant_cycle_id"`
Owner RespOwner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
}
// RespIndicator 指标
type RespIndicator struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
EntityType *string `json:"entity_type,omitempty"`
EntityID *string `json:"entity_id,omitempty"`
IndicatorStatus *string `json:"indicator_status,omitempty"`
StatusCalculateType *string `json:"status_calculate_type,omitempty"`
StartValue *float64 `json:"start_value,omitempty"`
TargetValue *float64 `json:"target_value,omitempty"`
CurrentValue *float64 `json:"current_value,omitempty"`
CurrentValueCalculateType *string `json:"current_value_calculate_type,omitempty"`
Unit *RespIndicatorUnit `json:"unit,omitempty"`
}
// RespIndicatorUnit 指标单位
type RespIndicatorUnit struct {
UnitType *string `json:"unit_type,omitempty"`
UnitValue *string `json:"unit_value,omitempty"`
}
// RespKeyResult 关键结果
type RespKeyResult struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *string `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// RespObjective 目标
type RespObjective struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *string `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *string `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
KeyResults []RespKeyResult `json:"key_results,omitempty"`
}
// RespOwner OKR 所有者
type RespOwner struct {
OwnerType string `json:"owner_type"`
UserID *string `json:"user_id,omitempty"`
}

View File

@@ -1,182 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"time"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
var OKRCycleDetail = common.Shortcut{
Service: "okr",
Command: "+cycle-detail",
Description: "List objectives and key results under an OKR cycle",
Risk: "read",
Scopes: []string{"okr:okr.content:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if cycleID == "" {
return common.FlagErrorf("--cycle-id is required")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--cycle-id must be a positive int64")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
params := map[string]interface{}{
"page_size": 100,
}
return common.NewDryRunAPI().
GET("/open-apis/okr/v2/cycles/:cycle_id/objectives").
Params(params).
Set("cycle_id", cycleID).
Desc("Auto-paginates objectives in the cycle, then calls GET /open-apis/okr/v2/objectives/:objective_id/key_results for each objective to fetch key results")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
// Paginate objectives under the cycle.
queryParams := make(larkcore.QueryParams)
queryParams.Set("page_size", "100")
var objectives []Objective
page := 0
for {
if err := ctx.Err(); err != nil {
return err
}
if page > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var obj Objective
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
objectives = append(objectives, obj)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
}
// For each objective, paginate key results and convert to response format.
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
krQuery := make(larkcore.QueryParams)
krQuery.Set("page_size", "100")
var keyResults []KeyResult
krPage := 0
for {
if err := ctx.Err(); err != nil {
return err
}
if krPage > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
krPage++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
keyResults = append(keyResults, kr)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
krQuery.Set("page_token", pageToken)
}
respObj := obj.ToResp()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
return nil
},
}

View File

@@ -1,561 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"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"
)
// --- helpers ---
func cycleDetailTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-okr-detail-" + suffix,
AppSecret: "secret-okr-detail-" + suffix,
Brand: core.BrandFeishu,
}
}
func runCycleDetailShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRCycleDetail.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func decodeEnvelope(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
}
// --- Validate tests ---
func TestCycleDetailValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail"})
if err == nil {
t.Fatal("expected error for missing --cycle-id")
}
// cobra catches required flag before our Validate runs
if !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleDetailValidate_InvalidCycleID_NonNumeric(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "abc"})
if err == nil {
t.Fatal("expected error for non-numeric --cycle-id")
}
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleDetailValidate_InvalidCycleID_Zero(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
if err == nil {
t.Fatal("expected error for zero --cycle-id")
}
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "-1"})
if err == nil {
t.Fatal("expected error for negative --cycle-id")
}
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
// Need to register stubs because Validate passes and Execute runs
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "123"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestCycleDetailDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{
"+cycle-detail",
"--cycle-id", "456",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "456") {
t.Fatalf("dry-run output should contain cycle-id 456, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/456/objectives") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
}
// --- Execute tests ---
func TestCycleDetailExecute_NoObjectives(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/100/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "100"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
if data["cycle_id"] != "100" {
t.Fatalf("cycle_id = %v, want 100", data["cycle_id"])
}
objs, _ := data["objectives"].([]interface{})
if len(objs) != 0 {
t.Fatalf("objectives = %v, want empty", objs)
}
}
func TestCycleDetailExecute_WithObjectivesAndKeyResults(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
// Stub for objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/200/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "obj-1",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"cycle_id": "200",
"score": 0.8,
"weight": 1.0,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{
"text": "Improve team productivity",
},
},
},
},
},
},
},
},
},
},
},
})
// Stub for key results of obj-1
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/obj-1/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "kr-1",
"objective_id": "obj-1",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"score": 0.9,
"weight": 0.5,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{
"text": "Reduce response time by 50%",
},
},
},
},
},
},
},
},
},
},
},
})
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "200"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
if data["cycle_id"] != "200" {
t.Fatalf("cycle_id = %v, want 200", data["cycle_id"])
}
objs, _ := data["objectives"].([]interface{})
if len(objs) != 1 {
t.Fatalf("objectives count = %d, want 1", len(objs))
}
obj, _ := objs[0].(map[string]interface{})
if obj["id"] != "obj-1" {
t.Fatalf("objective id = %v, want obj-1", obj["id"])
}
krs, _ := obj["key_results"].([]interface{})
if len(krs) != 1 {
t.Fatalf("key results count = %d, want 1", len(krs))
}
kr, _ := krs[0].(map[string]interface{})
if kr["id"] != "kr-1" {
t.Fatalf("key result id = %v, want kr-1", kr["id"])
}
}
func TestCycleDetailExecute_Pagination(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
// First page of objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/300/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "obj-p1",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"cycle_id": "300",
"score": 0.5,
"weight": 1.0,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "Page1 obj"},
},
},
},
},
},
},
},
},
"has_more": true,
"page_token": "next_page_token",
},
},
})
// Second page of objectives (no more)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/300/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "obj-p2",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"cycle_id": "300",
"score": 0.6,
"weight": 1.0,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "Page2 obj"},
},
},
},
},
},
},
},
},
},
},
})
// Key results for obj-p1: first page with has_more=true
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "kr-p1-1",
"objective_id": "obj-p1",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"score": 0.7,
"weight": 0.5,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p1"},
},
},
},
},
},
},
},
},
"has_more": true,
"page_token": "kr-p1-next",
},
},
})
// Key results for obj-p1: second page with has_more=false
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "kr-p1-2",
"objective_id": "obj-p1",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"score": 0.8,
"weight": 0.5,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p1"},
},
},
},
},
},
},
},
},
},
},
})
// Key results for obj-p2: first page with has_more=true
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "kr-p2-1",
"objective_id": "obj-p2",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"score": 0.6,
"weight": 0.4,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p2"},
},
},
},
},
},
},
},
},
"has_more": true,
"page_token": "kr-p2-next",
},
},
})
// Key results for obj-p2: second page with has_more=false
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "kr-p2-2",
"objective_id": "obj-p2",
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"score": 0.9,
"weight": 0.6,
"content": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"block_type": 1,
"paragraph": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"element_type": 1,
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p2"},
},
},
},
},
},
},
},
},
},
},
})
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "300"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
objs, _ := data["objectives"].([]interface{})
if len(objs) != 2 {
t.Fatalf("objectives count = %d, want 2", len(objs))
}
// Verify key_results are aggregated across pages for each objective
for i, objRaw := range objs {
obj, _ := objRaw.(map[string]interface{})
objID, _ := obj["id"].(string)
krs, _ := obj["key_results"].([]interface{})
if len(krs) != 2 {
t.Fatalf("objective[%d] %s: key_results count = %d, want 2", i, objID, len(krs))
}
// Verify KR IDs are distinct (from different pages)
krIDs := make(map[string]bool)
for _, krRaw := range krs {
kr, _ := krRaw.(map[string]interface{})
krID, _ := kr["id"].(string)
krIDs[krID] = true
}
if len(krIDs) != 2 {
t.Fatalf("objective %s: expected 2 distinct KR IDs, got %v", objID, krIDs)
}
}
}
func TestCycleDetailExecute_APIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/400/objectives",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "internal error",
},
})
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "400"})
if err == nil {
t.Fatal("expected error for API failure")
}
}

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
// The start is the first moment of the start month; the end is the last moment of the end month.
func parseTimeRange(s string) (start, end time.Time, err error) {
parts := strings.SplitN(s, "--", 2)
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
}
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
}
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
}
// end is the last moment of the end month
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
if start.After(end) {
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
}
return start, end, nil
}
// cycleOverlaps checks whether a cycle's [startMs, endMs] overlaps with [rangeStart, rangeEnd].
func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs)
cycleEnd := time.UnixMilli(endMs)
// Two ranges overlap iff one starts before the other ends
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
}
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
Description: "List okr cycles of a certain user",
Risk: "read",
Scopes: []string{"okr:okr.period:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id", Desc: "user ID", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
}
userID := runtime.Str("user-id")
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
return err
}
tr := runtime.Str("time-range")
if tr != "" {
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
return err
}
if _, _, err := parseTimeRange(tr); err != nil {
return common.FlagErrorf("--time-range: %s", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id": runtime.Str("user-id"),
"user_id_type": runtime.Str("user-id-type"),
"page_size": 100,
}
return common.NewDryRunAPI().
GET("/open-apis/okr/v2/cycles").
Params(params).
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
userID := runtime.Str("user-id")
userIDType := runtime.Str("user-id-type")
timeRange := runtime.Str("time-range")
// Parse time range for filtering
var rangeStart, rangeEnd time.Time
var hasRange bool
if timeRange != "" {
var err error
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
if err != nil {
return common.FlagErrorf("--time-range: %s", err)
}
hasRange = true
}
// Paginated fetch of all cycles
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id", userID)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("page_size", "100")
var allCycles []Cycle
page := 0
for {
if err := ctx.Err(); err != nil {
return err
}
if page > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var cycle Cycle
if err := json.Unmarshal(raw, &cycle); err != nil {
continue
}
allCycles = append(allCycles, cycle)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
}
// Filter by time-range overlap
var filtered []Cycle
for i := range allCycles {
if !hasRange || cycleOverlaps(&allCycles[i], rangeStart, rangeEnd) {
filtered = append(filtered, allCycles[i])
}
}
// Convert to response format
respCycles := make([]*RespCycle, 0, len(filtered))
for i := range filtered {
respCycles = append(respCycles, filtered[i].ToResp())
}
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
"total": len(respCycles),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
for _, c := range respCycles {
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
}
})
return nil
},
}

View File

@@ -1,448 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
// --- helpers ---
func cycleListTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-okr-list-" + suffix,
AppSecret: "secret-okr-list-" + suffix,
Brand: core.BrandFeishu,
}
}
func runCycleListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRListCycles.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestCycleListValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--user-id-type", "invalid_type",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
if !strings.Contains(err.Error(), "--user-id-type must be one of") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleListValidate_ControlCharsInUserID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-\t123",
"--user-id-type", "open_id",
})
if err == nil {
t.Fatal("expected error for control chars in --user-id")
}
}
func TestCycleListValidate_ControlCharsInTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--user-id-type", "open_id",
"--time-range", "2025-01\t--2025-06",
})
if err == nil {
t.Fatal("expected error for control chars in --time-range")
}
}
func TestCycleListValidate_InvalidTimeRangeFormat(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--time-range", "2025-01-2025-06",
})
if err == nil {
t.Fatal("expected error for invalid --time-range format")
}
if !strings.Contains(err.Error(), "--time-range") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--time-range", "2025-06--2025-01",
})
if err == nil {
t.Fatal("expected error for start after end in --time-range")
}
if !strings.Contains(err.Error(), "--time-range") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleListValidate_ValidWithTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--time-range", "2025-01--2025-06",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCycleListValidate_AllUserIDTypes(t *testing.T) {
t.Parallel()
for _, idType := range []string{"open_id", "union_id", "user_id"} {
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "test-id",
"--user-id-type", idType,
})
if err != nil {
t.Fatalf("user-id-type=%q: unexpected error: %v", idType, err)
}
}
}
// --- DryRun tests ---
func TestCycleListDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-456",
"--user-id-type", "open_id",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "ou-456") {
t.Fatalf("dry-run output should contain user-id ou-456, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
}
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-789",
"--time-range", "2025-01--2025-06",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
}
// --- Execute tests ---
func TestCycleListExecute_NoCycles(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
if len(cycles) != 0 {
t.Fatalf("cycles = %v, want empty", cycles)
}
}
func TestCycleListExecute_WithCycles(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-1",
"start_time": "1735689600000",
"end_time": "1751318400000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-1",
"score": 0.75,
},
map[string]interface{}{
"id": "cycle-2",
"start_time": "1704067200000",
"end_time": "1719792000000",
"cycle_status": 2,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-2",
"score": 0.5,
},
},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
if len(cycles) != 2 {
t.Fatalf("cycles count = %d, want 2", len(cycles))
}
total, _ := data["total"].(float64)
if int(total) != 2 {
t.Fatalf("total = %v, want 2", total)
}
}
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// Return two cycles: one inside the range, one outside
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-in-range",
"start_time": "1735689600000", // 2025-01-01
"end_time": "1738368000000", // 2025-02-01
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
},
map[string]interface{}{
"id": "cycle-out-range",
"start_time": "1704067200000", // 2024-01-01
"end_time": "1706745600000", // 2024-02-01
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
},
},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--time-range", "2025-01--2025-06",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
if len(cycles) != 1 {
t.Fatalf("cycles count = %d, want 1 (only cycle-in-range should pass filter)", len(cycles))
}
cycle, _ := cycles[0].(map[string]interface{})
if cycle["id"] != "cycle-in-range" {
t.Fatalf("cycle id = %v, want cycle-in-range", cycle["id"])
}
}
func TestCycleListExecute_Pagination(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// First page
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-p1",
"start_time": "1735689600000",
"end_time": "1738368000000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
},
},
"has_more": true,
"page_token": "next_page",
},
},
})
// Second page
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-p2",
"start_time": "1738368000000",
"end_time": "1743465600000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
},
},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
if len(cycles) != 2 {
t.Fatalf("cycles count = %d, want 2", len(cycles))
}
}
func TestCycleListExecute_APIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "internal error",
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
})
if err == nil {
t.Fatal("expected error for API failure")
}
}

View File

@@ -1,361 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"encoding/json"
"strconv"
"time"
)
// CycleStatus 周期状态
type CycleStatus int32
const (
CycleStatusDefault CycleStatus = 0
CycleStatusNormal CycleStatus = 1
CycleStatusInvalid CycleStatus = 2
CycleStatusHidden CycleStatus = 3
)
func (t CycleStatus) Ptr() *CycleStatus { return &t }
// StatusCalculateType 状态计算类型
type StatusCalculateType int32
const (
StatusCalculateTypeManualUpdate StatusCalculateType = 0
StatusCalculateTypeAutomaticallyUpdatesBasedOnProgressAndCurrentTime StatusCalculateType = 1
StatusCalculateTypeStatusUpdatesBasedOnTheHighestRiskKeyResults StatusCalculateType = 2
)
// BlockElementType 块元素类型
type BlockElementType string
const (
BlockElementTypeGallery BlockElementType = "gallery"
BlockElementTypeParagraph BlockElementType = "paragraph"
)
func (t BlockElementType) Ptr() *BlockElementType { return &t }
// CategoryName 分类名称
type CategoryName struct {
Zh *string `json:"zh,omitempty"`
En *string `json:"en,omitempty"`
Ja *string `json:"ja,omitempty"`
}
// ListType 列表类型
type ListType string
const (
ListTypeBullet ListType = "bullet"
ListTypeCheckBox ListType = "checkBox"
ListTypeCheckedBox ListType = "checkedBox"
ListTypeIndent ListType = "indent"
ListTypeNumber ListType = "number"
)
// OwnerType 所有者类型
type OwnerType string
const (
OwnerTypeDepartment OwnerType = "department"
OwnerTypeUser OwnerType = "user"
)
// ParagraphElementType 段落元素类型
type ParagraphElementType string
const (
ParagraphElementTypeDocsLink ParagraphElementType = "docsLink"
ParagraphElementTypeMention ParagraphElementType = "mention"
ParagraphElementTypeTextRun ParagraphElementType = "textRun"
)
func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t }
// ContentBlock 内容块
type ContentBlock struct {
Blocks []ContentBlockElement `json:"blocks,omitempty"`
}
// ContentBlockElement 内容块元素
type ContentBlockElement struct {
BlockElementType *BlockElementType `json:"block_element_type,omitempty"`
Paragraph *ContentParagraph `json:"paragraph,omitempty"`
Gallery *ContentGallery `json:"gallery,omitempty"`
}
// ContentColor 颜色
type ContentColor struct {
Red *int32 `json:"red,omitempty"`
Green *int32 `json:"green,omitempty"`
Blue *int32 `json:"blue,omitempty"`
Alpha *float64 `json:"alpha,omitempty"`
}
// ContentDocsLink 文档链接
type ContentDocsLink struct {
URL *string `json:"url,omitempty"`
Title *string `json:"title,omitempty"`
}
// ContentGallery 图库
type ContentGallery struct {
Images []ContentImageItem `json:"images,omitempty"`
}
// ContentImageItem 图片项
type ContentImageItem struct {
FileToken *string `json:"file_token,omitempty"`
Src *string `json:"src,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
// ContentLink 链接
type ContentLink struct {
URL *string `json:"url,omitempty"`
}
// ContentList 列表
type ContentList struct {
ListType *ListType `json:"list_type,omitempty"`
IndentLevel *int32 `json:"indent_level,omitempty"`
Number *int32 `json:"number,omitempty"`
}
// ContentMention 提及
type ContentMention struct {
UserID *string `json:"user_id,omitempty"`
}
// ContentParagraph 段落
type ContentParagraph struct {
Style *ContentParagraphStyle `json:"style,omitempty"`
Elements []ContentParagraphElement `json:"elements,omitempty"`
}
// ContentParagraphElement 段落元素
type ContentParagraphElement struct {
ParagraphElementType *ParagraphElementType `json:"paragraph_element_type,omitempty"`
TextRun *ContentTextRun `json:"text_run,omitempty"`
DocsLink *ContentDocsLink `json:"docs_link,omitempty"`
Mention *ContentMention `json:"mention,omitempty"`
}
// ContentParagraphStyle 段落样式
type ContentParagraphStyle struct {
List *ContentList `json:"list,omitempty"`
}
// ContentTextRun 文本块
type ContentTextRun struct {
Text *string `json:"text,omitempty"`
Style *ContentTextStyle `json:"style,omitempty"`
}
// ContentTextStyle 文本样式
type ContentTextStyle struct {
Bold *bool `json:"bold,omitempty"`
StrikeThrough *bool `json:"strike_through,omitempty"`
BackColor *ContentColor `json:"back_color,omitempty"`
TextColor *ContentColor `json:"text_color,omitempty"`
Link *ContentLink `json:"link,omitempty"`
}
// Cycle 周期
type Cycle struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
TenantCycleID string `json:"tenant_cycle_id"`
Owner Owner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *CycleStatus `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
}
// KeyResult 关键结果
type KeyResult struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner Owner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *ContentBlock `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// Objective 目标
type Objective struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner Owner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *ContentBlock `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *ContentBlock `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// Owner OKR 所有者
type Owner struct {
OwnerType OwnerType `json:"owner_type"`
UserID *string `json:"user_id,omitempty"`
}
// ToString CycleStatus to string
func (t CycleStatus) ToString() string {
switch t {
case CycleStatusDefault:
return "default"
case CycleStatusNormal:
return "normal"
case CycleStatusInvalid:
return "invalid"
case CycleStatusHidden:
return "hidden"
default:
return ""
}
}
// formatTimestamp 格式化毫秒级时间戳为 DateTime 格式
func formatTimestamp(ts string) string {
if ts == "" {
return ""
}
millis, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ts
}
t := time.UnixMilli(millis)
return t.Format("2006-01-02 15:04:05")
}
// ToResp converts Cycle to RespCycle
func (c *Cycle) ToResp() *RespCycle {
if c == nil {
return nil
}
resp := &RespCycle{
ID: c.ID,
CreateTime: formatTimestamp(c.CreateTime),
UpdateTime: formatTimestamp(c.UpdateTime),
TenantCycleID: c.TenantCycleID,
Owner: *c.Owner.ToResp(),
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
Score: c.Score,
}
if c.CycleStatus != nil {
s := c.CycleStatus.ToString()
resp.CycleStatus = &s
}
return resp
}
// ToResp converts KeyResult to RespKeyResult
func (k *KeyResult) ToResp() *RespKeyResult {
if k == nil {
return nil
}
result := &RespKeyResult{
ID: k.ID,
CreateTime: formatTimestamp(k.CreateTime),
UpdateTime: formatTimestamp(k.UpdateTime),
Owner: *k.Owner.ToResp(),
ObjectiveID: k.ObjectiveID,
Position: k.Position,
Score: k.Score,
Weight: k.Weight,
}
if k.Deadline != nil {
d := formatTimestamp(*k.Deadline)
result.Deadline = &d
}
// Serialize ContentBlock to JSON string (only if Content is not nil and has blocks)
if k.Content != nil && len(k.Content.Blocks) > 0 {
if bytes, err := json.Marshal(k.Content); err == nil {
s := string(bytes)
result.Content = &s
}
}
return result
}
// ToResp converts Objective to RespObjective
func (o *Objective) ToResp() *RespObjective {
if o == nil {
return nil
}
result := &RespObjective{
ID: o.ID,
CreateTime: formatTimestamp(o.CreateTime),
UpdateTime: formatTimestamp(o.UpdateTime),
Owner: *o.Owner.ToResp(),
CycleID: o.CycleID,
Position: o.Position,
Score: o.Score,
Weight: o.Weight,
CategoryID: o.CategoryID,
}
if o.Deadline != nil {
d := formatTimestamp(*o.Deadline)
result.Deadline = &d
}
// Serialize Content to JSON string
if o.Content != nil && len(o.Content.Blocks) > 0 {
if bytes, err := json.Marshal(o.Content); err == nil {
s := string(bytes)
result.Content = &s
}
}
// Serialize Notes to JSON string
if o.Notes != nil && len(o.Notes.Blocks) > 0 {
if bytes, err := json.Marshal(o.Notes); err == nil {
s := string(bytes)
result.Notes = &s
}
}
return result
}
// ToResp converts Owner to RespOwner
func (o *Owner) ToResp() *RespOwner {
if o == nil {
return nil
}
return &RespOwner{
OwnerType: string(o.OwnerType),
UserID: o.UserID,
}
}
// ptrStr dereferences a string pointer, returning "" for nil.
func ptrStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// ptrFloat64 dereferences a float64 pointer, returning 0 for nil.
func ptrFloat64(p *float64) float64 {
if p == nil {
return 0
}
return *p
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"testing"
"github.com/smartystreets/goconvey/convey"
)
func TestFormatTimestamp(t *testing.T) {
convey.Convey("formatTimestamp", t, func() {
convey.Convey("empty string returns empty", func() {
result := formatTimestamp("")
convey.So(result, convey.ShouldEqual, "")
})
convey.Convey("valid timestamp formats correctly", func() {
result := formatTimestamp("1735689600000")
// 不检查具体的时分秒,因为时区不同结果会不同
convey.So(result, convey.ShouldStartWith, "2025-01-01")
})
convey.Convey("invalid timestamp returns original", func() {
result := formatTimestamp("not-a-number")
convey.So(result, convey.ShouldEqual, "not-a-number")
})
})
}
func TestToRespMethods(t *testing.T) {
convey.Convey("ToResp methods handle nil", t, func() {
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
})
convey.Convey("ToResp methods work with valid objects", t, func() {
convey.Convey("Cycle", func() {
cycle := &Cycle{
ID: "cycle-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
TenantCycleID: "tenant-cycle-id",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
StartTime: "1735689600000",
EndTime: "1751318400000",
CycleStatus: CycleStatusNormal.Ptr(),
Score: float64Ptr(0.75),
}
resp := cycle.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
})
convey.Convey("Objective", func() {
obj := &Objective{
ID: "obj-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
CycleID: "cycle-id",
Position: int32Ptr(1),
Score: float64Ptr(0.8),
Weight: float64Ptr(1.0),
Deadline: strPtr("1751318400000"),
Content: &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Test objective"),
},
},
},
},
},
},
},
}
resp := obj.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
})
convey.Convey("KeyResult", func() {
kr := &KeyResult{
ID: "kr-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
ObjectiveID: "obj-id",
Position: int32Ptr(1),
Content: &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Test KR"),
},
},
},
},
},
},
},
Score: float64Ptr(0.9),
Weight: float64Ptr(0.5),
Deadline: strPtr("1751318400000"),
}
resp := kr.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
})
})
}
// strPtr returns a pointer to the given string value.
func strPtr(v string) *string { return &v }
// int32Ptr returns a pointer to the given int32 value.
func int32Ptr(v int32) *int32 { return &v }
// float64Ptr returns a pointer to the given float64 value.
func float64Ptr(v float64) *float64 { return &v }

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"github.com/larksuite/cli/shortcuts/common"
)
// Shortcuts returns all okr shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
OKRListCycles,
OKRCycleDetail,
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"testing"
"github.com/smartystreets/goconvey/convey"
)
func TestShortcutsRegistration(t *testing.T) {
convey.Convey("Shortcuts() returns all commands", t, func() {
list := Shortcuts()
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}

View File

@@ -4,7 +4,6 @@
package shortcuts
import (
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
@@ -46,7 +45,6 @@ func init() {
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
}
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).

View File

@@ -1,333 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func dataValidationBasePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
validate.EncodePathSegment(token))
}
func dataValidationSheetPath(token, sheetID string) string {
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
}
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
arr[i] = s
}
return arr, nil
}
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
}
}
return ranges, nil
}
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
if err != nil {
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
}
dv := map[string]interface{}{
"conditionValues": condValues,
}
opts := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("multiple") {
opts["multipleValues"] = runtime.Bool("multiple")
}
if runtime.Cmd.Flags().Changed("highlight") {
opts["highlightValidData"] = runtime.Bool("highlight")
}
if runtime.Str("colors") != "" {
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
if err != nil {
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
}
opts["colors"] = colors
}
if len(opts) > 0 {
dv["options"] = opts
}
return dv, nil
}
// SheetSetDropdown sets dropdown list validation on a range.
var SheetSetDropdown = common.Shortcut{
Service: "sheets",
Command: "+set-dropdown",
Description: "Set dropdown list on a cell range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateDropdown updates dropdown list settings for given ranges.
var SheetUpdateDropdown = common.Shortcut{
Service: "sheets",
Command: "+update-dropdown",
Description: "Update dropdown list settings",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, err := validateRangesFlag(runtime); err != nil {
return err
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
Body(map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetDropdown queries dropdown list settings for a range.
var SheetGetDropdown = common.Shortcut{
Service: "sheets",
Command: "+get-dropdown",
Description: "Get dropdown list settings for a range",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
Set("token", token).Set("range", runtime.Str("range"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
}, nil,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
var SheetDeleteDropdown = common.Shortcut{
Service: "sheets",
Command: "+delete-dropdown",
Description: "Delete dropdown list from cell ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
_, err := validateRangesFlag(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"dataValidationRanges": dvRanges,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -1,552 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── SetDropdown ─────────────────────────────────────────────────────────────
func TestSetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": "not-json",
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"mixed types", `["ok", 1, null]`},
{"all numbers", `[1, 2, 3]`},
{"null literal", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": tc.input,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
}
})
}
}
func TestSetDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "bad-json",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "A2:A100", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `[]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
"colors": `["#FF0000"]`,
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors length") {
t.Fatalf("expected length mismatch error, got: %v", err)
}
}
func TestSetDropdownValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": true, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation: %s", got)
}
if !strings.Contains(got, `"dataValidationType":"list"`) {
t.Fatalf("DryRun missing dataValidationType: %s", got)
}
}
func TestSetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dv, _ := body["dataValidation"].(map[string]interface{})
opts, _ := dv["options"].(map[string]interface{})
if opts["multipleValues"] != true {
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
}
if opts["highlightValidData"] != true {
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
}
}
func TestSetDropdownExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestSetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── UpdateDropdown ──────────────────────────────────────────────────────────
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": "not-json", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `[]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "{not-array}",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestUpdateDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `sheet1`) {
t.Fatalf("DryRun missing sheet_id: %s", got)
}
}
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["new1","new2"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["opt1"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetDropdown ─────────────────────────────────────────────────────────────
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestGetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation path: %s", got)
}
}
func TestGetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{
map[string]interface{}{
"dataValidationType": "list",
"conditionValues": []interface{}{"opt1", "opt2"},
"ranges": []interface{}{"s1!A2:A100"},
},
},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "dataValidations") {
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
}
}
func TestGetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteDropdown ──────────────────────────────────────────────────────────
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestDeleteDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dataValidationRanges`) {
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
}
}
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"rangeResults": []interface{}{
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
},
}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "rangeResults") {
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
}
}
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dvRanges, _ := body["dataValidationRanges"].([]interface{})
if len(dvRanges) != 2 {
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
}
}
func TestDeleteDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// suppress unused import for bytes in case the test helpers already import it
var _ = (*bytes.Buffer)(nil)

View File

@@ -36,9 +36,5 @@ func Shortcuts() []common.Shortcut {
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
}
}

Some files were not shown because too many files have changed in this diff Show More