Compare commits

..

1 Commits

Author SHA1 Message Date
梁硕
c6b57311b2 docs: add v1.0.6 changelog
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:50:08 +08:00
966 changed files with 12875 additions and 206876 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

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

@@ -0,0 +1,135 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
- name: Summarize CLI E2E test report
if: ${{ !cancelled() }}
run: |
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
report_path = "cli-e2e-report.xml"
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
root = ET.parse(report_path).getroot()
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
tests = failures = errors = skipped = 0
failed_cases = []
skipped_cases = []
for suite in suites:
tests += int(suite.attrib.get("tests", 0))
failures += int(suite.attrib.get("failures", 0))
errors += int(suite.attrib.get("errors", 0))
skipped += int(suite.attrib.get("skipped", 0))
for case in suite.findall("testcase"):
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
label = f"{classname}.{name}" if classname else name
failure = case.find("failure")
error = case.find("error")
skipped_node = case.find("skipped")
if failure is not None or error is not None:
message = ""
node = failure if failure is not None else error
if node is not None:
message = node.attrib.get("message", "") or (node.text or "").strip()
failed_cases.append((label, message))
elif skipped_node is not None:
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
skipped_cases.append((label, message))
passed = tests - failures - errors - skipped
with open(summary_path, "a", encoding="utf-8") as f:
f.write("## CLI E2E Test Report\n\n")
f.write(f"- Total: {tests}\n")
f.write(f"- Passed: {passed}\n")
f.write(f"- Failed: {failures}\n")
f.write(f"- Errors: {errors}\n")
f.write(f"- Skipped: {skipped}\n\n")
if failed_cases:
f.write("### Failed Tests\n\n")
for label, message in failed_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
if skipped_cases:
f.write("### Skipped Tests\n\n")
for label, message in skipped_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
PY

58
.github/workflows/coverage.yml vendored 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 }}

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

View File

@@ -45,15 +45,6 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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 ./...

5
.gitignore vendored
View File

@@ -31,11 +31,6 @@ tests/mail/reports/
/log/
# Generated / test artifacts
.hammer/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo

View File

@@ -27,7 +27,6 @@ linters:
- reassign # checks that package variables are not reassigned
- unconvert # removes unnecessary type conversions
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
# To enable later after fixing existing issues:
@@ -46,7 +45,6 @@ linters:
linters:
- bodyclose
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
linters:
@@ -54,84 +52,81 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
# internal/ legitimately wraps raw HTTP for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
settings:
depguard:
rules:
shortcuts-no-vfs:
files:
- "**/shortcuts/**"
deny:
- pkg: "github.com/larksuite/cli/internal/vfs"
desc: >-
shortcuts must not import internal/vfs directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
# intentionally allowed since they don't bypass the runtime layer.
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
# ── Filesystem operations: use internal/vfs instead ──
- pattern: os\.Stat\b
msg: "use vfs.Stat() from internal/vfs"
- pattern: os\.Lstat\b
msg: "use vfs.Lstat() from internal/vfs"
- pattern: os\.Open\b
msg: "use vfs.Open() from internal/vfs"
- pattern: os\.OpenFile\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.Create\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.CreateTemp\b
msg: >-
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
instead of constructing raw HTTP. The runtime handles auth, headers,
and error normalization. (Constants and helpers like http.MethodPost,
http.StatusOK, http.StatusText remain allowed.)
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
msg: >-
internal/: use vfs.CreateTemp() or vfs.OpenFile().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.Mkdir(All)?\b
internal/: use vfs.CreateTemp() from internal/vfs.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Mkdir\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.MkdirAll\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.Remove\b
msg: >-
internal/: use vfs.Remove() from internal/vfs.
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.RemoveAll\b
msg: >-
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
# ── os: not yet in vfs — add to vfs/fs.go first ──
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
# ── os: IO streams ──
- pattern: os\.Std(in|out|err)\b
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
# ── os: process ──
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Rename\b
msg: "use vfs.Rename() from internal/vfs"
- pattern: os\.ReadFile\b
msg: "use vfs.ReadFile() from internal/vfs"
- pattern: os\.WriteFile\b
msg: "use vfs.WriteFile() from internal/vfs"
- pattern: os\.ReadDir\b
msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()"
- pattern: os\.Getwd\b
msg: "use vfs.Getwd() from internal/vfs"
- pattern: os\.Chdir\b
msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()"
- pattern: os\.UserHomeDir\b
msg: "use vfs.UserHomeDir() from internal/vfs"
- pattern: os\.Chmod\b
msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()"
- pattern: os\.Chown\b
msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()"
- pattern: os\.Lchown\b
msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()"
- pattern: os\.Link\b
msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()"
- pattern: os\.Symlink\b
msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()"
- pattern: os\.Readlink\b
msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()"
- pattern: os\.Truncate\b
msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()"
- pattern: os\.DirFS\b
msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()"
- pattern: os\.SameFile\b
msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()"
# ── IO streams: use IOStreams from cmdutil instead ──
- pattern: os\.Stdin\b
msg: "use IOStreams.In instead of os.Stdin"
- pattern: os\.Stdout\b
msg: "use IOStreams.Out instead of os.Stdout"
- pattern: os\.Stderr\b
msg: "use IOStreams.ErrOut instead of os.Stderr"
# ── Process-level rules ──
- pattern: os\.Exit\b
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: >-
These filepath functions access the filesystem directly.
internal/: use vfs helpers or localfileio path validation.
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
analyze-types: true
gocritic:
disabled-checks:

View File

@@ -1,16 +0,0 @@
header:
license:
content: |
Copyright (c) [year] Lark Technologies Pte. Ltd.
SPDX-License-Identifier: MIT
copyright-year: "2026"
paths:
- '**/*.go'
- '**/*.js'
- '**/*.py'
paths-ignore:
- '**/testdata/**'
comment: on-failure

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,344 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.22] - 2026-04-29
### Features
- **task**: Add resource agent & `agent_task_step_info` (#693)
- **task**: Support app task members by id (#712)
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
- **slides**: Add slide templates with template-first skill guidance (#684)
- **mail**: Support calendar events in emails (#646)
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
### Bug Fixes
- **install**: Make Windows zip extraction resilient (#713)
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
### Documentation
- **base**: Clarify base search routing (#708)
- **base**: Align base skills and view config contracts (#653)
## [v1.0.21] - 2026-04-28
### Features
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
- **common**: Backfill resource URL when create APIs omit it (#680)
- **risk**: Add risk tiering for command sensitivity classification (#633)
- **okr**: Add progress records support (#574)
- **calendar**: Enhance event search and meeting room finding (#679)
- **event**: Add event subscription & consume system (#654)
- **drive**: Extend `+add-comment` to support slides targets (#674)
- **slides**: Add font management for slides (#681)
### Bug Fixes
- **cmdutil**: Default flag completions to disabled (#688)
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
- **readme**: Fix readme statistics (#691)
## [v1.0.20] - 2026-04-27
### Features
- **drive**: Add `+search` shortcut with flat filter flags (#658)
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
- **calendar**: Add `+update` shortcut (#678)
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
- **pagination**: Preserve pagination state on truncation and natural end (#659)
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
### Bug Fixes
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
### Documentation
- **lark-drive**: Add missing import command examples (#669)
- **readme**: Add Project (Meegle) to Features table (#660)
## [v1.0.19] - 2026-04-24
### Features
- **mail**: Add read receipt support — `--request-receipt` on compose, `+send-receipt` / `+decline-receipt` for response
- **doc**: Add v2 API for `docs +create` / `+fetch` / `+update` (#638)
- **im**: Request thread roots for chat message list (#635)
- **drive**: Support wiki node targets in `+upload` (#611)
- **config**: Block `auth` / `config` when external credential provider is active (#627)
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.10` in `lark-whiteboard` skill (#649)
## [v1.0.18] - 2026-04-23
### Features
- **base**: Support `.base` import and export for bitable (#599)
- **config**: Add `config bind` for per-Agent credential isolation (#515)
- **slides**: Add `+replace-slide` shortcut for block-level XML edits (#516)
- **wiki**: Add `+delete-space` shortcut with async task polling (#610)
- **doc**: Add `--from-clipboard` flag to `docs +media-insert` (#508)
- **minutes**: Unify minute artifacts output to `./minutes/{minute_token}/` (#604)
- Add configurable content-safety scanning (#606)
- **install**: Add SHA-256 checksum verification to `install.js` (#592)
- **whiteboard**: Pin `whiteboard-cli` to `^0.2.9` (#617)
### Bug Fixes
- **drive**: Escape angle brackets in comment text (#632)
- **im**: Unify `messages-search` pagination int flags (#446)
- **im**: Fix markdown URL rendering issues in post content (#206)
### Documentation
- **base**: Refine record cell value guidance (#636)
## [v1.0.17] - 2026-04-22
### Features
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
- **drive**: Add `+apply-permission` to request doc access (#588)
- Support record share link (#466)
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
### Bug Fixes
- **base**: Add default-table follow-up hint to `base-create` (#600)
- Skip flag-completion registration outside completion path (#598)
- Add `record-share-link-create` in `SKILL.md` (#597)
- **mail**: Remove leftover conflict marker in skill docs (#594)
### Documentation
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
## [v1.0.16] - 2026-04-21
### Features
- **mail**: Support large email attachments (#537)
- **mail**: Add draft preview URL to draft operations (#438)
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
- **calendar**: Support event share link and error details (#583)
### Bug Fixes
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
- **docs**: Validate `--selection-by-title` format early (#256)
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
### Refactor
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
- **auth**: Simplify scope reporting in login flow (#582)
## [v1.0.15] - 2026-04-20
### Features
- **sheets**: Add float image shortcuts (#494)
- **approval**: Document `remind` and `initiated` methods in skill (#554)
### Bug Fixes
- **base**: Preserve attachment metadata on base uploads (#563)
- **base**: Fix role view and record default permission on edit (#530)
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
- **install**: Refine install wizard messages (#529)
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
## [v1.0.14] - 2026-04-17
### Features
- **mail**: Add email priority support for compose and read (#538)
- **mail**: Support scheduled send (#534)
- **drive**: Support sheet cell comments in `+add-comment` (#518)
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
- **base**: Auto grant current user for bot create and copy (#497)
- **base**: Add identity priority strategy and error handling (#505)
- **auth**: Improve login scope handling and messages (#523)
- Add OKR business domain (#522)
### Documentation
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
### Refactor
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
## [v1.0.13] - 2026-04-16
### Features
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
### Bug Fixes
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
### Documentation
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
### CI
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
## [v1.0.12] - 2026-04-15
### Features
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
- **mail**: Return recall hints for sent emails when recall is available (#481)
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
### Documentation
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
- **im**: Support im oapi range download for large files (#283)
- **sheets**: Add filter view and condition shortcuts (#422)
- **wiki**: Add wiki move shortcut with async task polling (#436)
- **drive**: Add drive `+create-shortcut` shortcut (#432)
- **drive**: Add drive files patch metadata API (#444)
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
### Bug Fixes
- **base**: Support large base attachment uploads (#441)
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
- **mail**: Restrict `--output-dir` to current working directory (#376)
### Documentation
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
- **task**: Document sections API resources, permissions, and URL parsing (#430)
- **doc**: Clarify when markdown escaping is needed (#312)
## [v1.0.9] - 2026-04-11
### Features
- Add attendance `user_task.query` (#405)
- Support minutes search (#359)
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
- **slides**: Return presentation URL in slides `+create` output (#425)
- **sheets**: Add dimension shortcuts for row/column operations (#413)
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
- **drive**: Add drive folder delete shortcut with async task polling (#415)
### Documentation
- **drive**: Add guide for granting document permission to current bot (#414)
## [v1.0.8] - 2026-04-10
### Features
- Add `update` command with self-update, verification, and rollback (#391)
- Add `--file` flag for multipart/form-data file uploads (#395)
- Support file comment reply reactions (#380)
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
- **base**: Add `+record-search` for keyword-based record search (#328)
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
- **base**: Add record field filters (#327)
- **base**: Optimize workflow skills (#345)
- **calendar**: Add room find workflow (#403)
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
### Bug Fixes
- Improve error hints for sandbox and initialization issues (#384)
- Fix markdown line breaks support (#338)
- Return raw base field and view responses (#378)
- **base**: Return raw table list response and clarify sort help (#393)
- **calendar**: Add default video meeting to `+create` (#383)
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
### Documentation
- **base**: Document Base attachment download via docs `+media-download` (#404)
- Reorganize lark-base skill guidance (#374)
## [v1.0.7] - 2026-04-09
### Features
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
- **vc**: Extract note doc tokens from calendar event relation API (#333)
- **wiki**: Add wiki node create shortcut (#320)
- **sheets**: Add `+write-image` shortcut (#343)
- **docs**: Add media-preview shortcut (#334)
- **docs**: Add support for additional search filters (#353)
### Bug Fixes
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
- **run**: Add missing binary check for lark-cli execution (#362)
- **config**: Validate appId and appSecret keychain key consistency (#295)
### Refactor
- Route base import guidance to drive `+import` (#368)
- Migrate mail shortcuts to FileIO (#356)
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
- Migrate base shortcuts to FileIO (#347)
### Documentation
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
### Chore
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
## [v1.0.6] - 2026-04-08
### Features
@@ -560,22 +222,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -30,16 +30,12 @@ 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 |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
## Installation & Quick Start
@@ -140,7 +136,6 @@ lark-cli auth status
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
@@ -152,11 +147,9 @@ lark-cli auth status
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
## Authentication
@@ -203,7 +196,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -30,16 +30,12 @@
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
## 安装与快速开始
@@ -141,7 +137,6 @@ lark-cli auth status
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
@@ -153,11 +148,9 @@ lark-cli auth status
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-okr` | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
## 认证
@@ -204,7 +197,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -5,6 +5,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -41,7 +42,17 @@ type APIOptions struct {
Format string
JqExpr string
DryRun bool
File string
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
@@ -57,10 +68,6 @@ func normalisePath(raw string) string {
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
return NewCmdApiWithContext(context.Background(), f, runF)
}
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string
@@ -81,9 +88,9 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
@@ -92,7 +99,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
@@ -100,7 +106,10 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -108,24 +117,20 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
}
// buildAPIRequest validates flags and builds a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
}
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
params, err := parseJsonOpt(opts.Params, "--params")
if err != nil {
return client.RawApiRequest{}, nil, err
return client.RawApiRequest{}, err
}
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize
@@ -135,53 +140,14 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
Method: opts.Method,
URL: normalisePath(opts.Path),
Params: params,
Data: data,
As: opts.As,
}
if opts.File != "" {
// File upload path: build formdata.
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
if opts.DryRun {
return request, &cmdutil.FileUploadMeta{
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
}, nil
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = data
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
return request, nil, nil
return request, nil
}
func apiRun(opts *APIOptions) error {
@@ -199,7 +165,7 @@ func apiRun(opts *APIOptions) error {
return err
}
request, fileMeta, err := buildAPIRequest(opts)
request, err := buildAPIRequest(opts)
if err != nil {
return err
}
@@ -210,9 +176,6 @@ func apiRun(opts *APIOptions) error {
}
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
@@ -239,13 +202,12 @@ func apiRun(opts *APIOptions) error {
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).

View File

@@ -5,7 +5,6 @@ package api
import (
"errors"
"os"
"sort"
"strings"
"testing"
@@ -180,24 +179,6 @@ func TestApiValidArgsFunction(t *testing.T) {
}
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -218,22 +199,6 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -725,98 +690,3 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
}
}
func TestApiCmd_FileFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.File != "image=photo.jpg" {
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
}
}
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file with --output")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutual exclusion error, got: %v", err)
}
}
func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file with GET")
}
if !strings.Contains(err.Error(), "requires POST") {
t.Errorf("expected method error, got: %v", err)
}
}
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file stdin with --data stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_DryRunWithFile(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.jpg"
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -24,16 +24,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
},
}
cmdutil.DisableAuthCheck(cmd)

View File

@@ -5,19 +5,15 @@ package auth
import (
"context"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -307,72 +303,3 @@ func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credenti
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
// simulating env/sidecar mode for guard tests.
type stubExternalProvider struct{ name string }
func (s *stubExternalProvider) Name() string { return s.name }
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
// extension provider, simulating env/sidecar credential mode.
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExternalProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestAuthBlockedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdAuth(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
}
})
}
}

View File

@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})
@@ -134,7 +134,18 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains()
domainSet := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
domainSet[p] = true
}
for _, sc := range shortcuts.AllShortcuts() {
domainSet[sc.Service] = true
}
selectedDomains = make([]string, 0, len(domainSet))
for d := range domainSet {
selectedDomains = append(selectedDomains, d)
}
sort.Strings(selectedDomains)
break
}
}
@@ -440,8 +451,6 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// collectScopesForDomains collects API scopes (from from_meta projects) and
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
scopeSet := make(map[string]bool)
@@ -450,16 +459,11 @@ func collectScopesForDomains(domains []string, identity string) []string {
scopeSet[s] = true
}
// 2. Expand domains: include auth_domain children
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
domainSet := make(map[string]bool, len(domains))
for _, d := range domains {
domainSet[d] = true
for _, child := range registry.GetAuthChildren(d) {
domainSet[child] = true
}
}
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
@@ -468,7 +472,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
}
}
// 4. Deduplicate and sort
// 3. Deduplicate and sort
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
@@ -477,20 +481,14 @@ func collectScopesForDomains(domains []string, identity string) []string {
return result
}
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
func allKnownDomains() map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
domains[p] = true
}
domains[p] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
domains[sc.Service] = true
}
return domains
}

View File

@@ -34,12 +34,8 @@ func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects (skip domains with auth_domain)
// 1. Domains from from_meta projects
for _, project := range registry.ListFromMetaProjects() {
if registry.HasAuthDomain(project) {
seen[project] = true
continue
}
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
@@ -56,14 +52,13 @@ func getDomainMetadata(lang string) []domainMeta {
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
// (skip domains with auth_domain — they are folded into their parent)
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
if shortcutOnlySet[sc.Service] {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
@@ -184,6 +179,27 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,

View File

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

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

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
@@ -128,7 +125,7 @@ func emptyIfNil(s []string) []string {
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted scope
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
@@ -136,6 +133,7 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
@@ -189,13 +187,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

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

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,10 +376,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -391,18 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -471,13 +469,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"授权成功! 用户: tester (ou_user)",
"登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -492,10 +490,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -510,9 +508,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -621,9 +619,10 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -635,12 +634,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
@@ -750,7 +743,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 授权成功! 用户: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -778,18 +771,16 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
got := stderr.String()
for _, want := range []string{
"Authorization successful! User: tester (ou_user)",
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "Not granted scopes:") {
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
@@ -912,37 +903,3 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
}
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
if !domains["docs"] {
t.Error("docs should still be a known auth domain")
}
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {
if strings.HasPrefix(s, "board:whiteboard:") {
found = true
break
}
}
if !found {
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
}
}
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "whiteboard" {
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
}
}
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// BuildOption configures optional aspects of the command tree construction.
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
c.streams = cmdutil.NewIOStreams(in, out, errOut)
}
}
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
return func(c *buildConfig) {
c.keychain = kc
}
}
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
// HideProfile(isSingleAppMode()).
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
}
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
for _, o := range opts {
if o != nil {
o(cfg)
}
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
// the same values the Factory ends up using.
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
rootCmd.SetContext(ctx)
rootCmd.SetIn(cfg.streams.In)
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return f, rootCmd
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a zero-side-effect KeychainAccess for exercising
// WithKeychain without touching the platform keychain.
type noopKeychain struct{}
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopKeychain) Set(service, account, value string) error { return nil }
func (noopKeychain) Remove(service, account string) error { return nil }
// TestBuild_ExternalAPI asserts the library surface that external consumers
// (e.g. cli-server) depend on: Build composes a root command from an
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
var in, out, errOut bytes.Buffer
rootCmd := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(&in, &out, &errOut),
WithKeychain(noopKeychain{}),
HideProfile(true),
)
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
if len(rootCmd.Commands()) == 0 {
t.Error("Build produced a root command with no subcommands")
}
}
// TestBuild_NoOptions guards against regression of the nil-streams panic:
// calling Build without WithIO must fall back to SystemIO rather than
// deref nil at rootCmd.SetIn/Out/Err.
func TestBuild_NoOptions(t *testing.T) {
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"runtime"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
// *cobra.Command instances into cobra's package-global flag-completion map.
//
// This guards the new default (completions disabled) — if someone flips the
// zero-value back to "enabled", the per-Build memory growth observed under
// `scripts/bench_build` would resurface in production hot paths that build
// the root command without serving a completion request.
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
if cmdutil.FlagCompletionsEnabled() {
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
}
snap := func() (heapMB float64, objs uint64) {
runtime.GC()
runtime.GC()
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
}
// Warm one-time caches (registry JSON decode, embed reads) so the first
// Build's lazy allocations don't skew the per-iteration delta.
_ = Build(context.Background(), cmdutil.InvocationContext{})
baseMB, baseObj := snap()
const N = 20
for range N {
_ = Build(context.Background(), cmdutil.InvocationContext{})
}
mb, obj := snap()
deltaMB := mb - baseMB
deltaObj := int64(obj) - int64(baseObj)
perBuildKB := deltaMB * 1024 / float64(N)
perBuildObj := deltaObj / int64(N)
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
// With completions disabled (the default), per-Build retained growth
// should be minimal. Threshold is conservative: the previously observed
// leak with completions enabled was ~hundreds of KB and thousands of
// objects per Build, well above this bound.
const maxKBPerBuild = 50.0
const maxObjsPerBuild = 500
if perBuildKB > maxKBPerBuild {
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
}
if perBuildObj > maxObjsPerBuild {
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
}
}

View File

@@ -1,586 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// BindOptions holds all inputs for config bind.
type BindOptions struct {
Factory *cmdutil.Factory
Source string
AppID string
// Identity selects one of two presets — "bot-only" or "user-default" —
// that expand to underlying StrictMode + DefaultAs in applyPreferences.
// Empty means "decide later": TUI prompts, flag mode defaults to bot-only
// (the safer choice — bot acts under its own identity, no impersonation
// risk; users can still opt into "user-default" via --identity).
Identity string
// Force opts in to an otherwise-blocked flag-mode transition — currently
// only the bot-only → user-default identity escalation. TUI mode ignores
// this flag because its own prompts already require human confirmation.
Force bool
Lang string
langExplicit bool // true when --lang was explicitly passed
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
// that run before that (source / account selection) render brand-aware
// text with an empty value, which brandDisplay falls back to Feishu.
Brand string
// IsTUI is the resolved interactive-mode flag: true only when Source is
// empty and stdin is a terminal. Computed once at the top of
// configBindRun; downstream branches read this instead of rechecking
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
IsTUI bool
}
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f}
cmd := &cobra.Command{
Use: "bind",
Short: "Bind Agent config to a workspace (source / app-id / force)",
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
For AI agents: pass --source and --app-id to bind non-interactively.
Credentials are synced once; subsequent calls in the Agent's process
context automatically use the bound workspace.`,
Example: ` lark-cli config bind --source openclaw --app-id <id>
lark-cli config bind --source hermes`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
return runF(opts)
}
return configBindRun(opts)
},
}
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
return cmd
}
// configBindRun is the top-level orchestrator. Each step delegates to a named
// helper whose signature declares its contract; the body reads as the shape of
// the bind flow itself, not its mechanics.
func configBindRun(opts *BindOptions) error {
if err := validateBindFlags(opts); err != nil {
return err
}
// Decide TUI-vs-flag mode exactly once; every downstream branch reads
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
source, err := finalizeSource(opts)
if err != nil {
return err
}
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
if err != nil {
return err
}
if existing.Cancelled {
return nil
}
appConfig, err := resolveAccount(opts, source)
if err != nil {
return err
}
opts.Brand = string(appConfig.Brand)
if err := resolveIdentity(opts); err != nil {
return err
}
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
// existingBinding is the outcome of checking whether a workspace was already
// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller
// should pass it to commitBinding for stale-keychain cleanup after the new
// config is durably written). Cancelled is true iff the user declined to
// replace it in the TUI prompt; the caller should exit cleanly.
type existingBinding struct {
ConfigBytes []byte
Cancelled bool
}
// finalizeSource returns the validated bind source, reconciling three inputs:
// - opts.Source: the value of --source (may be empty)
// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv
// - TUI mode: can prompt the user if neither flag nor env yields a source
//
// Resolution (in order):
// 1. If --source is a non-empty invalid value → fail with ErrValidation.
// 2. If both --source and an env signal are present and disagree → fail
// loud; the user almost certainly ran the command in the wrong context.
// 3. TUI mode only: prompt for language first (so later prompts respect it).
// 4. --source wins if set. Otherwise use the env-detected source. Otherwise
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
func finalizeSource(opts *BindOptions) (string, error) {
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
if explicit != "" && explicit != "openclaw" && explicit != "hermes" {
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
}
var detected string
switch core.DetectWorkspaceFromEnv(os.Getenv) {
case core.WorkspaceOpenClaw:
detected = "openclaw"
case core.WorkspaceHermes:
detected = "hermes"
}
// Explicit and env detection must agree when both are present. Reject
// before any interactive prompts — running inside Hermes with
// --source openclaw (or vice versa) is almost always a mistake.
if explicit != "" && detected != "" && explicit != detected {
return "", output.ErrWithHint(output.ExitValidation, "bind",
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
"remove --source to auto-detect, or run this command in the correct Agent context")
}
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it.
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection("")
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
}
opts.Lang = lang
}
if explicit != "" {
return explicit, nil
}
if detected != "" {
return detected, nil
}
if opts.IsTUI {
return tuiSelectSource(opts)
}
return "", output.ErrWithHint(output.ExitValidation, "bind",
"cannot determine Agent source: no --source flag and no Agent environment detected",
"pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat")
}
// reconcileExistingBinding reads any existing config at configPath and decides
// how to proceed. In TUI mode the user is prompted to keep or replace. In flag
// mode the existing binding is silently overwritten — commitBinding will emit a
// notice on success so the caller still sees that a rebind happened.
// See existingBinding for the returned fields.
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
oldConfigData, _ := vfs.ReadFile(configPath)
if oldConfigData == nil {
return existingBinding{}, nil
}
if opts.IsTUI {
action, err := tuiConflictPrompt(opts, source, configPath)
if err != nil {
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
return existingBinding{ConfigBytes: oldConfigData}, nil
}
return existingBinding{ConfigBytes: oldConfigData}, nil
}
// resolveAccount runs the source-agnostic bind flow: construct the binder,
// enumerate candidates, pick one via the shared decision layer, and build a
// ready-to-persist AppConfig. Adding a new bind source only requires
// implementing SourceBinder — none of the logic below needs to change.
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
}
candidates, err := binder.ListCandidates()
if err != nil {
return nil, err
}
picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI,
func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) })
if err != nil {
return nil, err
}
return binder.Build(picked.AppID)
}
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer
// preset (bot acts under its own identity, no impersonation). Users who
// want the broader capability set can pass --identity user-default.
func resolveIdentity(opts *BindOptions) error {
if opts.Identity != "" {
return nil
}
if opts.IsTUI {
id, err := tuiSelectIdentity(opts)
if err != nil {
return err
}
opts.Identity = id
return nil
}
opts.Identity = "bot-only"
return nil
}
// hasStrictBotLock reports whether the given config bytes declare a
// bot-only lock on at least one app. Unparseable input returns false — it
// signals "no enforceable lock to honor", consistent with how the rest of
// the bind flow treats a corrupt previous config (commitBinding will
// overwrite it cleanly).
func hasStrictBotLock(data []byte) bool {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return false
}
for _, app := range multi.Apps {
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
return true
}
}
return false
}
// warnIdentityEscalation surfaces the risk of a flag-mode bot-only →
// user-default identity change. Without --force, the CLI refuses so an AI
// Agent has to relay the warning to the user and get explicit opt-in before
// retrying. TUI mode is exempt: tuiConflictPrompt + tuiSelectIdentity
// already require human confirmation in-flow.
func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error {
if opts.IsTUI || opts.Force || previousConfigBytes == nil {
return nil
}
if opts.Identity != "user-default" {
return nil
}
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.Lang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsBot
case "user-default":
sm := core.StrictModeOff
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
if opts.Lang != "" {
appConfig.Lang = opts.Lang
}
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
// best-effort cleanup of stale keychain entries from the previous binding (if
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return output.Errorf(output.ExitInternal, "bind",
"failed to create workspace directory: %v", err)
}
data, err := json.MarshalIndent(multi, "", " ")
if err != nil {
return output.Errorf(output.ExitInternal, "bind",
"failed to marshal config: %v", err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return output.Errorf(output.ExitInternal, "bind",
"failed to write config %s: %v", configPath, err)
}
replaced := previousConfigBytes != nil
msg := getBindMsg(opts.Lang)
display := sourceDisplayName(source)
if replaced {
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
// noise. Flag mode (Agent orchestration, scripts, piped output) still
// gets the full envelope for programmatic consumption.
if opts.IsTUI {
return nil
}
envelope := map[string]interface{}{
"ok": true,
"workspace": source,
"app_id": appConfig.AppId,
"config_path": configPath,
"replaced": replaced,
"identity": opts.Identity,
}
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
return nil
}
// cleanupKeychainFromData removes keychain entries referenced by a previous
// config snapshot, skipping any entry whose keychain ID is still in use by
// the new app config. This prevents rebinding the same appId from deleting
// the secret that ForStorage just wrote (old and new secret share the same
// keychain key, derived from appId). Best-effort: errors are silently
// ignored (same contract as config init's cleanup).
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
keepID := ""
if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" {
keepID = keep.AppSecret.Ref.ID
}
for _, app := range multi.Apps {
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
continue
}
core.RemoveSecretStore(app.AppSecret, kc)
}
}
// ──────────────────────────────────────────────────────────────
// TUI helpers (huh forms, matching config init interactive style)
// ──────────────────────────────────────────────────────────────
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
var source string
// Pre-select based on detected env signals
detected := core.DetectWorkspaceFromEnv(os.Getenv)
switch detected {
case core.WorkspaceOpenClaw:
source = "openclaw"
case core.WorkspaceHermes:
source = "hermes"
default:
source = "openclaw" // default first option
}
// Resolve actual paths for display
openclawPath := resolveOpenClawConfigPath()
hermesEnvPath := resolveHermesEnvPath()
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
).
Value(&source),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
}
return source, nil
}
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.Lang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
if c.Label != "" {
label = fmt.Sprintf("%s (%s)", c.Label, c.AppID)
}
options = append(options, huh.NewOption(label, i))
}
var selected int
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Options(options...).
Value(&selected),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
return &candidates[selected], nil
}
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.Lang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
if data, err := vfs.ReadFile(configPath); err == nil {
var multi core.MultiAppConfig
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
app := multi.Apps[0]
existingSummary = fmt.Sprintf(msg.ConflictDesc,
source, app.AppId, app.Brand, configPath)
}
}
var action string
form := huh.NewForm(
huh.NewGroup(
huh.NewNote().
Title(msg.ConflictTitle).
Description(existingSummary),
huh.NewSelect[string]().
Options(
huh.NewOption(msg.ConflictForce, "force"),
huh.NewOption(msg.ConflictCancel, "cancel"),
).
Value(&action),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "cancel", nil
}
return "", err
}
return action, nil
}
// indent prepends two spaces to every line of s. Used to visually nest
// multi-line option descriptions under their label in tuiSelectIdentity.
func indent(s string) string {
return " " + strings.ReplaceAll(s, "\n", "\n ")
}
// validateBindFlags validates enum flags early, before any side effects.
func validateBindFlags(opts *BindOptions) error {
if opts.Identity != "" {
switch opts.Identity {
case "bot-only", "user-default":
default:
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
return nil
}
// tuiSelectIdentity prompts user to pick one of two identity presets.
// bot-only is listed first so Enter on the default highlight maps to the
// flag-mode default for consistency across the two modes, and also because
// bot-only is the safer preset (no impersonation risk).
//
// Layout: each option's description is embedded under its label using a
// multi-line option value. huh styles the whole option block (label +
// indented description) as selected / unselected, giving a clear visual
// mapping between picker rows and their explanations — the dynamic
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectIdentity).
Options(
huh.NewOption(botLabel, "bot-only"),
huh.NewOption(userLabel, "user-default"),
).
Value(&value),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
}
return value, nil
}

View File

@@ -1,172 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
// should appear; callers pass brandDisplay(brand, lang) at that position.
// English templates use %[N]s positional indices when the natural English
// order puts brand before source.
type bindMsg struct {
// Source selection.
// SelectSourceDesc format: brand.
SelectSource string
SelectSourceDesc string
SourceOpenClaw string // format: resolved config path.
SourceHermes string // format: resolved dotenv path.
// Account selection (OpenClaw multi-account).
// Format: source display name ("OpenClaw" | "Hermes"), brand.
SelectAccount string
// Conflict prompt.
ConflictTitle string
ConflictDesc string // format: workspace, appId, brand, configPath.
ConflictForce string
ConflictCancel string
ConflictCancelled string
// Post-bind agent-friendly message emitted in the stdout JSON envelope's
// "message" field. Written as imperative instructions to the agent reading
// the JSON — not as description for a human reader.
// MessageBotOnly format: app_id, source display name, brand.
// MessageUserDefault format: app_id, source display name, source display
// name (second source ref anchors the "run in this chat" directive).
// MessageUserDefault directs the Agent at the blocking single-call
// `auth login --recommend` flow: the CLI streams verification_url to
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
// real time, then blocks until the user authorizes in their own browser.
// The Agent also needs an explicit "do not navigate the URL yourself"
// guard — its own browser is sandboxed and cannot complete the user's
// authorization.
MessageBotOnly string
MessageUserDefault string
// Identity preset (collapses strict-mode + default-as into one choice).
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
// the huh Select options. IdentityBotOnlyDesc / IdentityUserDefaultDesc
// carry the longer explanation for each choice; tuiSelectIdentity
// embeds the description under its label as a multi-line option value,
// so huh renders the whole "label + indented description" block as one
// picker row and styles it selected / unselected as a unit. Dynamic
// DescriptionFunc was tried first but breaks here: a longer description
// on hover pushes the field's initial viewport, clipping the selected
// option row on terminals that fit the smaller description.
// IdentityBotOnlyDesc format: brand.
// IdentityUserDefaultDesc format: brand, brand.
SelectIdentity string
IdentityBotOnly string
IdentityUserDefault string
IdentityBotOnlyDesc string
IdentityUserDefaultDesc string
// Post-bind success notice printed to stderr once the workspace config
// has been durably written. Rendered as two parts joined with "\n":
// BindSuccessHeader — format: source display name.
// BindSuccessNotice — caveat about one-time sync.
// We intentionally do NOT emit a "replaced" suffix here (the TUI already
// asked the user to confirm overwrite; flag mode carries `replaced:true`
// in the stdout JSON envelope), and we do NOT emit an inline "next step"
// line for user-default (stderr is the human channel; agents read the
// MessageUserDefault field in the JSON envelope).
BindSuccessHeader string
BindSuccessNotice string
// IdentityEscalationMessage / IdentityEscalationHint are returned when a
// previous bind set the workspace to bot-only and a flag-mode (AI-driven)
// caller tries to rebind with --identity user-default without --force.
// The error asks the Agent to surface the risk to the user and re-run
// with --force only after explicit user confirmation. TUI mode does not
// hit this code path — tuiConflictPrompt + tuiSelectIdentity already
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
}
var bindMsgZh = &bindMsg{
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息并配置到 lark-cli 中",
SourceOpenClaw: "OpenClaw — 配置文件: %s",
SourceHermes: "Hermes — 配置文件: %s",
SelectAccount: "检测到 %s 中已配置多个%s应用请选择一个",
ConflictTitle: "检测到已有配置",
ConflictDesc: "%q 已配置 lark-cli:\n App ID: %s\n 品牌: %s\n 配置文件: %s",
ConflictForce: "修改配置",
ConflictCancel: "保留当前配置",
ConflictCancelled: "已保留当前配置",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
SelectIdentity: "你希望 AI 如何与你协作?",
IdentityBotOnly: "以机器人身份",
IdentityUserDefault: "以你的身份",
IdentityBotOnlyDesc: "AI 将在%s中以机器人的身份执行所有操作适合作为团队助手用于多人协作场景如群聊问答、团队通知、公共文档维护。",
IdentityUserDefaultDesc: "AI 将在%s中以你的名义执行所有操作如读写文档、搜索消息、修改日程等建议仅限个人使用。\n" +
"⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的%s数据。",
BindSuccessHeader: "配置成功lark-cli 已可在 %s 中使用。",
BindSuccessNotice: "注意:这是一次性同步,后续 Agent 配置变更不会自动更新到 lark-cli。如需重新同步请执行 `lark-cli config bind`",
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
}
var bindMsgEn = &bindMsg{
SelectSource: "Which Agent are you running?",
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
SourceOpenClaw: "OpenClaw — config: %s",
SourceHermes: "Hermes — config: %s",
// Args order (source, brand) matches the Chinese template; %[N]s lets the
// English reading order differ while the caller passes args in one order.
SelectAccount: "Multiple %[2]s apps configured in %[1]s — select one to continue.",
ConflictTitle: "Existing configuration found",
ConflictDesc: "lark-cli is already set up for %q:\n App ID: %s\n Brand: %s\n Config: %s",
ConflictForce: "Update config",
ConflictCancel: "Keep current config",
ConflictCancelled: "Current config kept. No changes made.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
SelectIdentity: "How should the AI work with you?",
IdentityBotOnly: "As bot",
IdentityUserDefault: "As you",
IdentityBotOnlyDesc: "Works under its own identity in %s. Best for group chats, team notifications, and shared documents.",
IdentityUserDefaultDesc: "Works under your identity in %s, managing docs, messages, calendar, and more on your behalf. Personal use only.\n" +
"⚠️ Don't share this bot with others or add it to group chats. It has access to your personal %s data.",
BindSuccessHeader: "All set! lark-cli is now ready to use in %s.",
BindSuccessNotice: "Note: This is a one-time sync. To re-sync future changes, run `lark-cli config bind`",
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
}
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
return bindMsgEn
}
return bindMsgZh
}
// brandDisplay returns the UI-friendly product name for the given brand
// identifier and display language. "lark" maps to "Lark" in both zh and en.
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand, lang string) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang == "en" {
return "Feishu"
}
return "飞书"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,414 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
// Candidate is the source-agnostic view of a bindable account.
// It carries only the identity fields needed by selectCandidate / TUI;
// secrets remain inside the SourceBinder implementation.
type Candidate struct {
AppID string
Label string
}
// SourceBinder abstracts a bind source (openclaw / hermes / future sources).
// Implementations only list candidates and build an AppConfig for a chosen
// candidate — they stay out of mode (TUI vs flag) and orchestration concerns.
type SourceBinder interface {
// Name returns the source identifier (used in error envelopes).
Name() string
// ConfigPath returns the resolved path to the source's config file.
ConfigPath() string
// ListCandidates enumerates bindable accounts from the source config.
// An empty slice is valid (selectCandidate will turn it into a typed error).
ListCandidates() ([]Candidate, error)
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
Build(appID string) (*core.AppConfig, error)
}
// newBinder constructs the SourceBinder for the given source name.
func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
switch source {
case "openclaw":
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
case "hermes":
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
default:
return nil, output.ErrValidation("unsupported source: %s", source)
}
}
// selectCandidate is the single source of truth for account-selection logic.
// Every bind source funnels through this function, so the "how many
// candidates × was --app-id given × is this TUI" policy is defined once.
//
// Decision matrix:
//
// candidates=0 → error "no app configured"
// appID set, match → selected
// appID set, no match → error + candidate list
// candidates=1, appID="" → auto-select
// candidates≥2, appID="", isTUI=true → tuiPrompt
// candidates≥2, appID="", isTUI=false → error + candidate list
//
// The last branch is the one that matters for flag-mode callers: an explicit
// --source must never silently drop into an interactive prompt just because
// stdin happens to be a terminal.
func selectCandidate(
binder SourceBinder,
candidates []Candidate,
appIDFlag string,
isTUI bool,
tuiPrompt func([]Candidate) (*Candidate, error),
) (*Candidate, error) {
src := binder.Name()
cfgBase := filepath.Base(binder.ConfigPath())
if len(candidates) == 0 {
// Reader succeeded but yielded nothing — e.g. every openclaw account
// is disabled. Missing-file / missing-field cases return typed errors
// from ListCandidates itself and never reach here.
switch src {
case "openclaw":
return nil, output.ErrWithHint(output.ExitValidation, src,
"no Feishu app configured in openclaw.json",
"configure channels.feishu.appId in openclaw.json")
default:
return nil, output.ErrValidation("%s: no app configured", src)
}
}
if appIDFlag != "" {
for i := range candidates {
if candidates[i].AppID == appIDFlag {
return &candidates[i], nil
}
}
return nil, output.ErrWithHint(output.ExitValidation, src,
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
}
if len(candidates) == 1 {
return &candidates[0], nil
}
if isTUI {
return tuiPrompt(candidates)
}
return nil, output.ErrWithHint(output.ExitValidation, src,
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
}
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
func formatCandidates(candidates []Candidate) string {
ids := make([]string, 0, len(candidates))
for _, c := range candidates {
label := c.AppID
if c.Label != "" {
label = fmt.Sprintf("%s (%s)", c.AppID, c.Label)
}
ids = append(ids, label)
}
return strings.Join(ids, "\n ")
}
// ──────────────────────────────────────────────────────────────
// openclawBinder
// ──────────────────────────────────────────────────────────────
type openclawBinder struct {
opts *BindOptions
path string
// Cached between ListCandidates and Build so we don't re-read / re-parse.
cfg *binding.OpenClawRoot
rawApps []binding.CandidateApp
}
func (b *openclawBinder) Name() string { return "openclaw" }
func (b *openclawBinder) ConfigPath() string { return b.path }
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadOpenClawConfig(b.path)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
fmt.Sprintf("cannot read %s: %v", b.path, err),
"verify OpenClaw is installed and configured")
}
if cfg.Channels.Feishu == nil {
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
"openclaw.json missing channels.feishu section",
"configure Feishu in OpenClaw first")
}
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
b.cfg = cfg
b.rawApps = raw
result := make([]Candidate, 0, len(raw))
for _, c := range raw {
result = append(result, Candidate{AppID: c.AppID, Label: c.Label})
}
return result, nil
}
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i]
break
}
}
if selected == nil {
return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: appID %q not in candidates", appID)
}
if selected.AppSecret.IsZero() {
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
"configure channels.feishu.appSecret in openclaw.json")
}
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
fmt.Sprintf("check appSecret configuration in %s", b.path))
}
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "openclaw",
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
}
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}
// ──────────────────────────────────────────────────────────────
// hermesBinder
// ──────────────────────────────────────────────────────────────
type hermesBinder struct {
opts *BindOptions
path string
envMap map[string]string // cached between ListCandidates and Build
}
func (b *hermesBinder) Name() string { return "hermes" }
func (b *hermesBinder) ConfigPath() string { return b.path }
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
envMap, err := readDotenv(b.path)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
fmt.Sprintf("failed to read Hermes config: %v", err),
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
}
appID := envMap["FEISHU_APP_ID"]
if appID == "" {
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
"run 'hermes setup' to configure Feishu credentials")
}
b.envMap = envMap
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil {
return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: Build called before ListCandidates")
}
if b.envMap["FEISHU_APP_ID"] != appID {
return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: appID %q does not match env", appID)
}
appSecret := b.envMap["FEISHU_APP_SECRET"]
if appSecret == "" {
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
"run 'hermes setup' to configure Feishu credentials")
}
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "hermes",
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
}
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
}, nil
}
// ──────────────────────────────────────────────────────────────
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
// Moved here from bind.go so bind.go can focus on orchestration.
// ──────────────────────────────────────────────────────────────
// sourceDisplayName returns the user-facing label for a source identifier,
// matching the casing used in bind_messages.go (OpenClaw / Hermes).
func sourceDisplayName(source string) string {
switch source {
case "openclaw":
return "OpenClaw"
case "hermes":
return "Hermes"
default:
return source
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//
// Note: HERMES_HOME is typically unset when users run bind from a regular
// terminal. When AI agents execute bind within a Hermes subprocess, HERMES_HOME
// may be set and should be respected.
func resolveHermesEnvPath() string {
hermesHome := os.Getenv("HERMES_HOME")
if hermesHome == "" {
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
hermesHome = filepath.Join(home, ".hermes")
}
return filepath.Join(hermesHome, ".env")
}
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
// chain as OpenClaw's src/config/paths.ts:
// 1. OPENCLAW_CONFIG_PATH env → exact file path
// 2. OPENCLAW_STATE_DIR env → <dir>/openclaw.json
// 3. OPENCLAW_HOME env → <home>/.openclaw/openclaw.json
// 4. ~/.openclaw/openclaw.json (default)
// 5. Legacy: ~/.clawdbot/clawdbot.json, ~/.openclaw/clawdbot.json
func resolveOpenClawConfigPath() string {
if p := os.Getenv("OPENCLAW_CONFIG_PATH"); p != "" {
return expandHome(p)
}
if stateDir := os.Getenv("OPENCLAW_STATE_DIR"); stateDir != "" {
dir := expandHome(stateDir)
return findConfigInDir(dir)
}
home := os.Getenv("OPENCLAW_HOME")
if home == "" {
h, err := vfs.UserHomeDir()
if err != nil || h == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
home = h
} else {
home = expandHome(home)
}
newDir := filepath.Join(home, ".openclaw")
if configFile := findConfigInDir(newDir); fileExists(configFile) {
return configFile
}
legacyDir := filepath.Join(home, ".clawdbot")
if configFile := findConfigInDir(legacyDir); fileExists(configFile) {
return configFile
}
return filepath.Join(newDir, "openclaw.json")
}
func findConfigInDir(dir string) string {
primary := filepath.Join(dir, "openclaw.json")
if fileExists(primary) {
return primary
}
legacy := filepath.Join(dir, "clawdbot.json")
if fileExists(legacy) {
return legacy
}
return primary
}
func fileExists(path string) bool {
_, err := vfs.Stat(path)
return err == nil
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") || path == "~" {
home, err := vfs.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
return path
}
// readDotenv reads a KEY=VALUE .env file. Comments (#) and blank lines skipped.
// Matches Hermes's load_env() in hermes_cli/config.py.
func readDotenv(path string) (map[string]string, error) {
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
result := make(map[string]string)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
if key != "" {
result[key] = value
}
}
return result, nil
}

View File

@@ -1,175 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// fakeBinder is a test double for SourceBinder. selectCandidate only touches
// Name and ConfigPath (for error messages); ListCandidates/Build are not called
// from selectCandidate, so we can leave them as no-ops.
type fakeBinder struct {
name string
path string
}
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
// guardrail that proves the non-TUI decision paths really do stay out of the
// interactive prompt — otherwise a green test could still hide a silent TUI.
func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) {
t.Helper()
return func([]Candidate) (*Candidate, error) {
t.Fatal("tuiPrompt must not be called in flag mode")
return nil, nil
}
}
// assertCandidate compares the full Candidate struct via DeepEqual so that
// any future field added to Candidate is covered automatically.
func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
t.Helper()
if got == nil {
t.Fatal("expected non-nil Candidate")
}
if !reflect.DeepEqual(*got, want) {
t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want)
}
}
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
})
}
func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// Locks in the generic fallback so that any future source added to
// newBinder gets a well-formed validation error on "zero candidates"
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "hermes: no app configured",
})
}
func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only", Label: "default"}}
got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"})
}
func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
}
func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
}
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
// Flag-mode with multiple candidates and no --app-id must produce a
// validation error and the candidate list, never an interactive prompt.
// isTUI is the single gate; a real terminal alone must not trigger TUI.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
}
func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_work", Label: "work"},
{AppID: "cli_home", Label: "home"},
}
var gotCandidates []Candidate
got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) {
gotCandidates = cs
return &cs[1], nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Whole-slice DeepEqual so additions to Candidate propagate to this check.
if !reflect.DeepEqual(gotCandidates, candidates) {
t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates)
}
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
}
func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
// Even with only one candidate, a wrong --app-id must error rather than
// silently auto-selecting. An explicit mismatch is always a user mistake,
// not a reason to override their intent.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",
})
}
func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
// An explicit --app-id short-circuits the prompt even in TUI mode: a
// flag the user typed should never be second-guessed by an interactive
// prompt asking the same question.
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_a"},
{AppID: "cli_b"},
}
got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}

View File

@@ -14,19 +14,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))

View File

@@ -6,16 +6,13 @@ package config
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -343,68 +340,3 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
t.Fatalf("error = %v, want mention of App Secret", err)
}
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubConfigExtProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestConfigBlockedByExternalProvider(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdConfig(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
}
})
}
}

View File

@@ -269,7 +269,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
if err != nil {
return err
}

View File

@@ -177,26 +177,17 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
if f.IOStreams.IsTerminal {
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
} else {
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("%v", err)

View File

@@ -10,56 +10,45 @@ import (
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
WaitingForScan string // active polling indicator
// Non-TTY (AI / non-interactive) variants — preserve original copy
OpenLinkNonTTY string // primary link prompt
WaitingForScanNonTTY string // passive waiting indicator
DetectedLarkTenant string
AppCreated string
ConfigSaved string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
WaitingForScan: "正在获取你的应用配置结果...",
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
WaitingForScanNonTTY: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
WaitingForScan: "Fetching configuration results...",
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
WaitingForScanNonTTY: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
func getInitMsg(lang string) *initMsg {

View File

@@ -48,20 +48,17 @@ 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,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {

View File

@@ -44,7 +44,7 @@ func configShowRun(opts *ConfigShowOptions) error {
config, err := core.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return notConfiguredError()
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
@@ -64,7 +64,6 @@ func configShowRun(opts *ConfigShowOptions) error {
users = strings.Join(userStrs, ", ")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"workspace": core.CurrentWorkspace().Display(),
"profile": app.ProfileName(),
"appId": app.AppId,
"appSecret": "****",
@@ -75,18 +74,3 @@ func configShowRun(opts *ConfigShowOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
return nil
}
// notConfiguredError returns the "not configured" error with a hint that
// points the user to the right next step: config init for the default local
// workspace, config bind for an Agent workspace that has not been bound yet.
func notConfiguredError() error {
ws := core.CurrentWorkspace()
if ws.IsLocal() {
return output.ErrWithHint(output.ExitValidation, "config",
"not configured",
"run: lark-cli config init")
}
return output.ErrWithHint(output.ExitValidation, ws.Display(),
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
}

View File

@@ -1,203 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
)
// ── Data types ────────────────────────────────────────────────────────
type diagMethodEntry struct {
Domain string `json:"domain"`
Type string `json:"type"` // "api" or "shortcut"
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
Scope string `json:"scope"` // minimum-privilege scope
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
}
type diagScopeInfo struct {
Scope string `json:"scope"`
Recommend bool `json:"recommend"`
InPriority bool `json:"in_priority"`
}
type diagOutput struct {
Methods []diagMethodEntry `json:"methods"`
Scopes []diagScopeInfo `json:"scopes"`
}
// ── Core logic ────────────────────────────────────────────────────────
// diagAllKnownDomains returns sorted, deduplicated domain names from both
// from_meta projects and shortcuts.
func diagAllKnownDomains() []string {
seen := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
seen[p] = true
}
for _, s := range shortcuts.AllShortcuts() {
if s.Service != "" {
seen[s.Service] = true
}
}
result := make([]string, 0, len(seen))
for d := range seen {
result = append(result, d)
}
sort.Strings(result)
return result
}
// methodKey uniquely identifies a method+scope pair for merging identities.
type methodKey struct {
domain string
typ string
method string
scope string
}
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
func diagBuild(domains []string) diagOutput {
recommend := registry.LoadAutoApproveSet()
identities := []string{"user", "bot"}
merged := make(map[methodKey]*diagMethodEntry)
allSC := shortcuts.AllShortcuts()
for _, domain := range domains {
for _, identity := range identities {
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
for _, scope := range ce.Scopes {
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
k := methodKey{domain, "api", method, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "api",
Method: method,
Scope: scope, Identity: []string{identity},
}
}
}
}
for _, sc := range allSC {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.ScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "shortcut",
Method: sc.Command,
Scope: scope, Identity: []string{identity},
}
}
}
}
}
}
methods := make([]diagMethodEntry, 0, len(merged))
scopeSet := make(map[string]bool)
for _, e := range merged {
methods = append(methods, *e)
scopeSet[e.Scope] = true
}
sort.Slice(methods, func(i, j int) bool {
if methods[i].Domain != methods[j].Domain {
return methods[i].Domain < methods[j].Domain
}
if methods[i].Type != methods[j].Type {
return methods[i].Type < methods[j].Type
}
if methods[i].Method != methods[j].Method {
return methods[i].Method < methods[j].Method
}
return methods[i].Scope < methods[j].Scope
})
scopeList := make([]string, 0, len(scopeSet))
for s := range scopeSet {
scopeList = append(scopeList, s)
}
sort.Strings(scopeList)
priorities := registry.LoadScopePriorities()
scopes := make([]diagScopeInfo, len(scopeList))
for i, s := range scopeList {
_, inPri := priorities[s]
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
}
return diagOutput{Methods: methods, Scopes: scopes}
}
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
if len(sc.AuthTypes) == 0 {
return identity == "user"
}
for _, a := range sc.AuthTypes {
if a == identity {
return true
}
}
return false
}
func appendUniq(ss []string, s string) []string {
for _, existing := range ss {
if existing == s {
return ss
}
}
return append(ss, s)
}
// ── Snapshot generation ───────────────────────────────────────────────
//
// Generates a JSON snapshot of all API methods and shortcuts with their
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
//
// Usage:
//
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
func TestScopeSnapshot(t *testing.T) {
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
if dir == "" {
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
}
registry.Init()
result := diagBuild(diagAllKnownDomains())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "snapshot.json")
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
}

View File

@@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
"run: npm update -g @larksuite/cli")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}
@@ -253,9 +253,8 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
}
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
"ok": allOK,
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {

View File

@@ -1,25 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
func describeAppMetaErr(err error) string {
msg := err.Error()
if url := authURLPattern.FindString(msg); url != "" {
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
}
const maxErrLen = 200
if len(msg) > maxErrLen {
return msg[:maxErrLen] + "…"
}
return msg
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
)
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly]点击链接申请并开通任一权限即可https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
got := describeAppMetaErr(errors.New(realisticPermError))
if len(got) > 400 {
t.Errorf("summary too long (%d chars): %q", len(got), got)
}
if !strings.Contains(got, "scope") {
t.Errorf("summary should mention scope requirement, got: %q", got)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
if !strings.Contains(got, wantURL) {
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
}
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
if strings.Contains(got, noise) {
t.Errorf("summary leaked noise %q: %q", noise, got)
}
}
}
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
long := strings.Repeat("x", 500)
got := describeAppMetaErr(errors.New(long))
if len(got) > 220 {
t.Errorf("unknown error not truncated, len=%d", len(got))
}
}
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
got := describeAppMetaErr(errors.New("network unreachable"))
if got != "network unreachable" {
t.Errorf("short err should pass through unchanged, got: %q", got)
}
}
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
got := describeAppMetaErr(errors.New(msg))
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("want larksuite URL extracted, got: %q", got)
}
}

View File

@@ -1,69 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
var domain string
cmd := &cobra.Command{
Use: "_bus",
Short: "Internal event bus daemon (do not call directly)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return err
}
tr := transport.New()
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
cancel()
case <-ctx.Done():
}
}()
return b.Run(ctx)
},
}
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
_ = cmd.Flags().MarkHidden("domain")
return cmd
}

View File

@@ -1,24 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
}
}

View File

@@ -1,371 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
type consumeCmdOpts struct {
params []string
jqExpr string
quiet bool
outputDir string
maxEvents int
timeout time.Duration
}
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
var o consumeCmdOpts
cmd := &cobra.Command{
Use: "consume <EventKey>",
Short: "Start consuming events for an EventKey",
Long: `Start consuming real-time events for the given EventKey.
The consume command connects to the event bus daemon (starting it if needed),
subscribes to the specified EventKey, and streams processed events to stdout.
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
pretty-printed formatting.
Use 'event list' to see all available EventKeys.
Use 'event schema <EventKey>' for parameter details.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConsume(cmd, f, args[0], o)
},
}
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
ignoreBrokenPipe()
cfg, err := f.Config()
if err != nil {
return err
}
paramMap, err := parseParams(o.params)
if err != nil {
return err
}
keyDef, ok := eventlib.Lookup(eventKey)
if !ok {
return unknownEventKeyErr(eventKey)
}
identity, err := resolveIdentity(cmd, f, keyDef)
if err != nil {
return err
}
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
}
}
outputDir := o.outputDir
if outputDir != "" {
safePath, err := sanitizeOutputDir(outputDir)
if err != nil {
return err
}
outputDir = safePath
}
domain := core.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon.
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
return err
}
apiClient, err := f.NewAPIClient()
if err != nil {
return err
}
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
preflightErrOut := f.IOStreams.ErrOut
if o.quiet {
preflightErrOut = io.Discard
}
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
switch {
case appVerErr != nil:
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
case appVer == nil:
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
if err := preflightEventTypes(pf); err != nil {
return err
}
if err := preflightScopes(cmd.Context(), pf); err != nil {
return err
}
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
if !o.quiet && f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
}
cancel()
case <-ctx.Done():
}
}()
errOut := f.IOStreams.ErrOut
if o.quiet {
errOut = io.Discard
}
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
watchStdinEOF(os.Stdin, cancel, errOut)
}
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}); err != nil {
return err
}
return nil
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
flagAs := core.Identity(cmd.Flag("as").Value.String())
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
if len(keyDef.AuthTypes) > 0 {
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
return "", err
}
}
return identity, nil
}
type preflightCtx struct {
factory *cmdutil.Factory
appID string
brand core.LarkBrand
eventKey string
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var storedScopes string
switch {
case pf.identity.IsBot():
if pf.appVer == nil {
return nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == core.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
}
storedScopes = result.Scopes
default:
return nil
}
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
}
return safe, nil
}
// resolveTenantToken fetches the app's tenant access token.
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
return "", output.ErrAuth("resolve tenant access token: %s", err)
}
if result == nil || result.Token == "" {
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
}
return result.Token, nil
}
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
)
func parseParams(raw []string) (map[string]string, error) {
m := make(map[string]string)
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
}
m[k] = v
}
return m, nil
}
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
go func() {
_, _ = io.Copy(io.Discard, r)
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
"To keep running: pass --max-events/--timeout for bounded run, "+
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
"or stop via SIGTERM instead of closing stdin.")
cancel()
}()
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"
)
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
select {
case <-ctx.Done():
case <-time.After(1 * time.Second):
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pr, _ := io.Pipe()
defer pr.Close()
watchStdinEOF(pr, cancel, io.Discard)
select {
case <-ctx.Done():
t.Fatal("watchStdinEOF cancelled without EOF")
case <-time.After(200 * time.Millisecond):
}
}
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var buf bytes.Buffer
watchStdinEOF(strings.NewReader(""), cancel, &buf)
select {
case <-ctx.Done():
got := buf.String()
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
if !strings.Contains(got, want) {
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
}
}
case <-time.After(1 * time.Second):
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}

View File

@@ -1,143 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
)
func TestParseParams(t *testing.T) {
cases := []struct {
name string
in []string
want map[string]string
wantSentry error
wantEcho string
}{
{
name: "empty input",
in: nil,
want: map[string]string{},
},
{
name: "single key=value",
in: []string{"mailbox=user@example.com"},
want: map[string]string{"mailbox": "user@example.com"},
},
{
name: "multiple pairs",
in: []string{"a=1", "b=2", "c=3"},
want: map[string]string{"a": "1", "b": "2", "c": "3"},
},
{
name: "value containing = is kept intact",
in: []string{"filter=foo=bar"},
want: map[string]string{"filter": "foo=bar"},
},
{
name: "empty value allowed",
in: []string{"key="},
want: map[string]string{"key": ""},
},
{
name: "duplicate key — last wins",
in: []string{"k=1", "k=2"},
want: map[string]string{"k": "2"},
},
{
name: "missing = separator",
in: []string{"mailbox"},
wantSentry: errInvalidParamFormat,
wantEcho: `"mailbox"`,
},
{
name: "leading = (empty key)",
in: []string{"=value"},
wantSentry: errInvalidParamFormat,
wantEcho: `"=value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseParams(tc.in)
if tc.wantSentry != nil {
if err == nil {
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
}
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
}
for k, v := range tc.want {
if got[k] != v {
t.Errorf("key %q: got %q, want %q", k, got[k], v)
}
}
})
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
in string
wantSentry error
}{
{
name: "relative path accepted",
in: "./output",
},
{
name: "nested relative path accepted",
in: "events/today",
},
{
name: "tilde rejected explicitly",
in: "~/events",
wantSentry: errOutputDirTilde,
},
{
name: "parent escape rejected",
in: "../outside",
wantSentry: errOutputDirUnsafe,
},
{
name: "absolute path rejected",
in: "/tmp/events",
wantSentry: errOutputDirUnsafe,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := sanitizeOutputDir(tc.in)
if tc.wantSentry != nil {
if err == nil {
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
}
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Errorf("expected non-empty safe path, got %q", got)
}
})
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "event",
Short: "Consume and manage real-time events",
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
}
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
return cmd
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/output"
)
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
var buf bytes.Buffer
if err := writeStopJSON(&buf, []stopResult{
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
}); err != nil {
t.Fatalf("writeStopJSON: %v", err)
}
var got struct {
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if len(got.Results) != 2 {
t.Fatalf("results len = %d, want 2", len(got.Results))
}
if got.Results[0]["status"] != "stopped" {
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
}
if got.Results[1]["status"] != "refused" {
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
}
buf.Reset()
if err := writeStopJSON(&buf, nil); err != nil {
t.Fatalf("writeStopJSON(nil): %v", err)
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
}
if got.Results == nil || len(got.Results) != 0 {
t.Errorf("results = %v, want []", got.Results)
}
}
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
var out, errOut bytes.Buffer
writeStopText(&out, &errOut, []stopResult{
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
})
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
t.Errorf("stopped line missing from stdout: %q", out.String())
}
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
t.Errorf("no-bus line missing from stdout: %q", out.String())
}
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
t.Errorf("refused line missing from stderr: %q", errOut.String())
}
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
t.Errorf("error line missing from stderr: %q", errOut.String())
}
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
t.Errorf("failure lines leaked to stdout: %q", out.String())
}
}
func TestBusState_String(t *testing.T) {
for _, tc := range []struct {
s busState
want string
}{
{stateNotRunning, "not_running"},
{stateRunning, "running"},
{stateOrphan, "orphan"},
} {
if got := tc.s.String(); got != tc.want {
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
}
}
}
func TestHumanizeDuration_AllBuckets(t *testing.T) {
for _, tc := range []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s ago"},
{90 * time.Second, "1m ago"},
{2 * time.Hour, "2h ago"},
{50 * time.Hour, "2d ago"},
} {
if got := humanizeDuration(tc.d); got != tc.want {
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
}
}
}
func TestWriteStatusText_CoversAllStates(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 3661,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
},
},
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
})
out := buf.String()
for _, want := range []string{
"── cli_NOTRUNNINGXXXXXX ──",
"Bus: not running",
"── cli_RUNNINGXXXXXXXXX ──",
"running (PID 1234",
"Active consumers: 2",
"im.message.receive_v1",
"── cli_ORPHANXXXXXXXXXX ──",
"orphan (PID 5678",
"Action: kill 5678",
} {
if !strings.Contains(out, want) {
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
}
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
}); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
var got struct {
Apps []map[string]interface{} `json:"apps"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
if len(got.Apps) != 2 {
t.Fatalf("apps len = %d", len(got.Apps))
}
orphan := got.Apps[0]
if orphan["status"] != "orphan" {
t.Errorf("orphan status = %v", orphan["status"])
}
if orphan["suggested_action"] != "kill 99" {
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
}
if orphan["issue"] == nil {
t.Error("orphan issue missing")
}
run := got.Apps[1]
if run["issue"] != nil {
t.Errorf("running entry leaked issue: %v", run["issue"])
}
if run["suggested_action"] != nil {
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
}
}
func TestExitForOrphan(t *testing.T) {
orphan := []appStatus{{State: stateOrphan}}
running := []appStatus{{State: stateRunning}}
if err := exitForOrphan(orphan, false); err != nil {
t.Errorf("flag off + orphan → nil expected, got %v", err)
}
if err := exitForOrphan(running, false); err != nil {
t.Errorf("flag off + running → nil expected, got %v", err)
}
if err := exitForOrphan(running, true); err != nil {
t.Errorf("flag on + no orphan → nil expected, got %v", err)
}
err := exitForOrphan(orphan, true)
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
*t = e
return true
}
}
return false
}
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("consume missing --%s flag", flag)
}
}
if cmd.RunE == nil {
t.Error("consume RunE is nil")
}
})
t.Run("status", func(t *testing.T) {
cmd := NewCmdStatus(f)
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("status missing --%s flag", flag)
}
}
})
t.Run("stop", func(t *testing.T) {
cmd := NewCmdStop(f)
for _, flag := range []string{"app-id", "all", "force", "json"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("stop missing --%s flag", flag)
}
}
})
t.Run("list", func(t *testing.T) {
cmd := NewCmdList(f)
if cmd.Flags().Lookup("json") == nil {
t.Error("list missing --json flag")
}
})
t.Run("bus", func(t *testing.T) {
cmd := NewCmdBus(f)
if !cmd.Hidden {
t.Error("bus should be hidden (internal daemon entrypoint)")
}
if cmd.Flags().Lookup("domain") == nil {
t.Error("bus missing --domain flag")
}
})
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "list",
Short: "List all available EventKeys",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
RunE: func(cmd *cobra.Command, args []string) error {
return runList(f, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
return cmd
}
func runList(f *cmdutil.Factory, asJSON bool) error {
all := eventlib.ListAll()
if asJSON {
return writeListJSON(f, all)
}
if len(all) == 0 {
// stderr so `event list | jq` doesn't ingest it as a row.
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
return nil
}
type group struct {
domain string
keys []*eventlib.KeyDefinition
}
order := []string{}
groups := map[string]*group{}
for _, def := range all {
domain := def.Key
if idx := strings.Index(def.Key, "."); idx > 0 {
domain = def.Key[:idx]
}
g, ok := groups[domain]
if !ok {
g = &group{domain: domain}
groups[domain] = g
order = append(order, domain)
}
g.keys = append(g.keys, def)
}
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
rowsByDomain := make(map[string][][]string, len(order))
var allRows [][]string
for _, domain := range order {
for _, def := range groups[domain].keys {
auth := "-"
if len(def.AuthTypes) > 0 {
auth = strings.Join(def.AuthTypes, "|")
}
desc := def.Description
if desc == "" {
desc = "-"
}
row := []string{
def.Key,
auth,
fmt.Sprintf("%d", len(def.Params)),
desc,
}
rowsByDomain[domain] = append(rowsByDomain[domain], row)
allRows = append(allRows, row)
}
}
out := f.IOStreams.Out
const colGap = " "
widths := tableWidths(headers, allRows)
printTableRow(out, widths, headers, colGap)
for _, domain := range order {
fmt.Fprintf(out, "\n── %s ──\n", domain)
for _, row := range rowsByDomain[domain] {
printTableRow(out, widths, row, colGap)
}
}
// stderr keeps stdout pipe-clean for `event list | jq`.
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
return nil
}
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
type row struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
rows := make([]row, len(all))
for i, def := range all {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
}
output.PrintJson(f.IOStreams.Out, rows)
return nil
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
_ "github.com/larksuite/cli/events"
)
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, false); err != nil {
t.Fatalf("runList: %v", err)
}
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
}
}
}
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, true); err != nil {
t.Fatalf("runList json: %v", err)
}
var rows []map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if len(rows) == 0 {
t.Fatal("expected at least one EventKey in JSON output")
}
for _, row := range rows {
for _, field := range []string{"key", "event_type", "schema"} {
if row[field] == nil {
t.Errorf("row missing %q: %+v", field, row)
}
}
}
}

View File

@@ -1,176 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
key := ""
if keyDef != nil {
key = keyDef.Key
}
return &preflightCtx{
appID: appID,
brand: brand,
eventKey: key,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
}
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
EventType: "im.message.receive_v1",
RequiredConsoleEvents: []string{"im.message.receive_v1"},
}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
}
}
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.message_read_v1",
EventType: "im.message.message_read_v1",
}
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
}
}
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.reaction",
EventType: "im.message.reaction.created_v1",
RequiredConsoleEvents: []string{
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
},
}
appVer := &appmeta.AppVersion{EventTypes: []string{
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.message.receive_v1",
}}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "mail.receive",
EventType: "mail.user_mailbox.event.message_received_v1",
RequiredConsoleEvents: []string{
"mail.user_mailbox.event.message_received_v1",
"mail.user_mailbox.event.message_read_v1",
},
}
appVer := &appmeta.AppVersion{EventTypes: []string{
"mail.user_mailbox.event.message_received_v1",
}}
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
if err == nil {
t.Fatal("expected error for missing subscription")
}
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
}
}
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
}
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{
"im:message",
"im:message.group_at_msg",
"contact:user:readonly",
}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
}
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
}
hint := exit.Detail.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
t.Errorf("hint missing %q\ngot: %s", want, hint)
}
}
}
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
type consumeRuntime struct {
client *client.APIClient
accessIdentity core.Identity
}
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
Method: method,
URL: path,
Data: body,
As: r.accessIdentity,
})
if err != nil {
return nil, err
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
const maxBodyEcho = 256
body := string(resp.RawBody)
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
return nil, err
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr
}
return json.RawMessage(resp.RawBody), nil
}

View File

@@ -1,223 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
"github.com/larksuite/cli/internal/output"
)
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil, nil, nil
}
base, err := renderSpec(spec)
if err != nil {
return nil, nil, err
}
if base == nil {
return nil, nil, nil
}
if isNative {
base = schemas.WrapV2Envelope(base)
}
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, err
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, err
}
return out, orphans, nil
}
return base, nil, nil
}
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
if s.Type != nil {
return schemas.FromType(s.Type), nil
}
if len(s.Raw) > 0 {
buf := make(json.RawMessage, len(s.Raw))
copy(buf, s.Raw)
return buf, nil
}
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "schema <EventKey>",
Short: "Show details for an EventKey",
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSchema(f, args[0], asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
return cmd
}
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
def, ok := eventlib.Lookup(key)
if !ok {
return unknownEventKeyErr(key)
}
if asJSON {
return writeSchemaJSON(f, def)
}
out := f.IOStreams.Out
fmt.Fprintf(out, "Key: %s\n", def.Key)
if def.Description != "" {
fmt.Fprintf(out, "Description: %s\n", def.Description)
}
fmt.Fprintf(out, "Event: %s\n", def.EventType)
if def.PreConsume != nil {
fmt.Fprintf(out, "Pre-consume: yes\n")
}
if len(def.Scopes) > 0 {
fmt.Fprintf(out, "\nRequired Scopes:\n")
for _, s := range def.Scopes {
fmt.Fprintf(out, " - %s\n", s)
}
}
if len(def.RequiredConsoleEvents) > 0 {
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
for _, e := range def.RequiredConsoleEvents {
fmt.Fprintf(out, " - %s\n", e)
}
}
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
defaultVal := p.Default
if defaultVal == "" {
defaultVal = "-"
}
desc := p.Description
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
}
w.Flush()
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
for _, p := range def.Params {
if len(p.Values) == 0 {
continue
}
fmt.Fprintf(out, "\n %s values:\n", p.Name)
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
for _, v := range p.Values {
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
}
vw.Flush()
}
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")
printIndentedJSON(out, resolved)
} else {
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
if def.Schema.Native != nil {
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
}
}
return nil
}
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
var parsed json.RawMessage
if err := json.Unmarshal(raw, &parsed); err != nil {
fmt.Fprintln(out, " <invalid JSON>")
return
}
formatted, err := json.MarshalIndent(parsed, " ", " ")
if err != nil {
return
}
fmt.Fprintf(out, " %s\n", string(formatted))
}
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
type payload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
var jqRootPath string
if resolved != nil {
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
_, isNative := pickSpec(def.Schema)
jqRootPath = "."
if isNative {
jqRootPath = ".event"
}
}
output.PrintJson(f.IOStreams.Out, payload{
KeyDefinition: def,
ResolvedSchema: resolved,
JQRootPath: jqRootPath,
})
return nil
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
_ "github.com/larksuite/cli/events"
)
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Key:", "im.message.receive_v1",
"Event:", "im.message.receive_v1",
"Output Schema:",
`"message_id"`,
} {
if !strings.Contains(out, want) {
t.Errorf("schema output missing %q; got:\n%s", want, out)
}
}
}
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Output Schema:",
`"schema"`,
`"header"`,
`"event"`,
} {
if !strings.Contains(out, want) {
t.Errorf("native schema output missing %q; got:\n%s", want, out)
}
}
}
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runSchema(f, "im.message.recieve_v1", false)
if err == nil {
t.Fatal("expected error for unknown key")
}
msg := err.Error()
if !strings.Contains(msg, "unknown EventKey") {
t.Errorf("error should mention unknown EventKey: %q", msg)
}
if !strings.Contains(msg, "im.message.receive_v1") {
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
}
}
func TestRunSchema_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
if _, ok := payload[field]; !ok {
t.Errorf("JSON output missing field %q: %+v", field, payload)
}
}
if payload["key"] != "im.message.receive_v1" {
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
type out struct {
SenderID string `json:"sender_id"`
}
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/sender_id": {Kind: "open_id"},
},
},
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
return nil, nil
},
})
def, _ := eventlib.Lookup(syntheticKey)
resolved, orphans, err := resolveSchemaJSON(def)
if err != nil || len(orphans) != 0 {
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
}
var parsed map[string]interface{}
if err := json.Unmarshal(resolved, &parsed); err != nil {
t.Fatal(err)
}
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
if got != "open_id" {
t.Errorf("overlay format = %v, want open_id", got)
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build unix
package event
import (
"os/signal"
"syscall"
)
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
func ignoreBrokenPipe() {
signal.Ignore(syscall.SIGPIPE)
}

View File

@@ -1,9 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package event
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
func ignoreBrokenPipe() {}

View File

@@ -1,328 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"io"
"sort"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
var (
asJSON bool
current bool
failOnOrphan bool
)
cmd := &cobra.Command{
Use: "status",
Short: "Show event bus daemon status for all discovered apps",
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus(f, current, asJSON, failOnOrphan)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
cmd.Flags().BoolVar(&current, "current", false, "Only show status for the current profile's app")
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
return cmd
}
type busState int
const (
stateNotRunning busState = iota
stateRunning
stateOrphan
)
func (s busState) String() string {
switch s {
case stateRunning:
return "running"
case stateOrphan:
return "orphan"
default:
return "not_running"
}
}
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
type appStatus struct {
AppID string
State busState
PID int
UptimeSec int
Active int
Consumers []protocol.ConsumerInfo
}
type busQuerier interface {
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
}
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
type singleAppScanner struct {
appID string
inner busdiscover.Scanner
}
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
if s.inner == nil {
return nil, nil
}
all, err := s.inner.ScanBusProcesses()
if err != nil {
return nil, err
}
out := all[:0]
for _, p := range all {
if p.AppID == s.appID {
out = append(out, p)
}
}
return out, nil
}
type transportQuerier struct {
tr transport.IPC
}
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
return busctl.QueryStatus(q.tr, appID)
}
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
cfg, err := f.Config()
if err != nil {
return err
}
seeds := map[string]struct{}{}
if current {
seeds[cfg.AppID] = struct{}{}
} else {
for _, id := range discoverAppIDs() {
seeds[id] = struct{}{}
}
// Always include the current profile so a first-time user sees it as not_running.
seeds[cfg.AppID] = struct{}{}
}
seedList := make([]string, 0, len(seeds))
for id := range seeds {
seedList = append(seedList, id)
}
tr := transport.New()
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
var scanner busdiscover.Scanner
if current {
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
} else {
scanner = busdiscover.Default()
}
statuses := deriveStatuses(
seedList,
scanner,
&transportQuerier{tr: tr},
time.Now(),
)
if asJSON {
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
return err
}
} else {
writeStatusText(f.IOStreams.Out, statuses)
}
return exitForOrphan(statuses, failOnOrphan)
}
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
procByAppID := map[string]busdiscover.Process{}
if sc != nil {
if procs, err := sc.ScanBusProcesses(); err == nil {
for _, p := range procs {
procByAppID[p.AppID] = p
}
}
}
ids := map[string]struct{}{}
for _, id := range seedAppIDs {
ids[id] = struct{}{}
}
for id := range procByAppID {
ids[id] = struct{}{}
}
sorted := make([]string, 0, len(ids))
for id := range ids {
sorted = append(sorted, id)
}
sort.Strings(sorted)
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
type probe struct {
resp *protocol.StatusResponse
err error
}
probes := make([]probe, len(sorted))
var wg sync.WaitGroup
for i, appID := range sorted {
wg.Add(1)
go func(i int, appID string) {
defer wg.Done()
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
}(i, appID)
}
wg.Wait()
result := make([]appStatus, 0, len(sorted))
for i, appID := range sorted {
s := appStatus{AppID: appID, State: stateNotRunning}
if probes[i].err == nil {
resp := probes[i].resp
s.State = stateRunning
s.PID = resp.PID
s.UptimeSec = resp.UptimeSec
s.Active = resp.ActiveConns
s.Consumers = resp.Consumers
} else if p, ok := procByAppID[appID]; ok {
s.State = stateOrphan
s.PID = p.PID
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
}
result = append(result, s)
}
return result
}
// humanizeDuration formats d as a coarse "N unit ago" string.
func humanizeDuration(d time.Duration) string {
s := int(d.Seconds())
if s < 60 {
return fmt.Sprintf("%ds ago", s)
}
m := s / 60
if m < 60 {
return fmt.Sprintf("%dm ago", m)
}
h := m / 60
if h < 24 {
return fmt.Sprintf("%dh ago", h)
}
return fmt.Sprintf("%dd ago", h/24)
}
func writeStatusText(out io.Writer, statuses []appStatus) {
for i, s := range statuses {
if i > 0 {
fmt.Fprintln(out)
}
fmt.Fprintf(out, "── %s ──\n", s.AppID)
switch s.State {
case stateNotRunning:
fmt.Fprintln(out, " Bus: not running")
case stateRunning:
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})
}
widths := tableWidths(headers, rows)
const colGap = " "
fmt.Fprintln(out)
fmt.Fprint(out, " ")
printTableRow(out, widths, headers, colGap)
for _, row := range rows {
fmt.Fprint(out, " ")
printTableRow(out, widths, row, colGap)
}
}
case stateOrphan:
if s.PID == 0 {
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
break
}
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
}
}
}
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
type jsonStatus struct {
AppID string `json:"app_id"`
Status string `json:"status"`
Running bool `json:"running"` // backward compat
PID int `json:"pid,omitempty"`
UptimeSec int `json:"uptime_sec,omitempty"`
Active int `json:"active_consumers,omitempty"`
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
Issue string `json:"issue,omitempty"`
SuggestedAction string `json:"suggested_action,omitempty"`
}
payload := make([]jsonStatus, 0, len(statuses))
for _, s := range statuses {
js := jsonStatus{
AppID: s.AppID,
Status: s.State.String(),
Running: s.State == stateRunning,
PID: s.PID,
UptimeSec: s.UptimeSec,
Active: s.Active,
Consumers: s.Consumers,
}
if s.State == stateOrphan {
if s.PID == 0 {
js.Issue = "live bus detected but pid file is missing or corrupt"
js.SuggestedAction = "inspect events dir and kill manually"
} else {
js.Issue = "socket file missing"
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
}
}
payload = append(payload, js)
}
output.PrintJson(w, map[string]interface{}{"apps": payload})
return nil
}
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
if !failOnOrphan {
return nil
}
for _, s := range statuses {
if s.State == stateOrphan {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestExitForOrphan_Orphan(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_a", State: stateRunning},
{AppID: "cli_b", State: stateOrphan, PID: 70926},
}
err := exitForOrphan(statuses, true)
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestExitForOrphan_NoOrphan(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_a", State: stateRunning},
{AppID: "cli_b", State: stateNotRunning},
}
if err := exitForOrphan(statuses, true); err != nil {
t.Errorf("expected nil error when no orphan; got %v", err)
}
}
func TestExitForOrphan_FlagDisabled(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_b", State: stateOrphan, PID: 70926},
}
if err := exitForOrphan(statuses, false); err != nil {
t.Errorf("flag off should never return error; got %v", err)
}
}

View File

@@ -1,242 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
)
type fakeScanner struct {
procs []busdiscover.Process
err error
}
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
return f.procs, f.err
}
type fakeBusQuerier struct {
respByAppID map[string]*protocol.StatusResponse
}
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
if r, ok := f.respByAppID[appID]; ok {
return r, nil
}
return nil, errors.New("dial failed")
}
func TestDeriveStatuses_RunningBus(t *testing.T) {
q := &fakeBusQuerier{
respByAppID: map[string]*protocol.StatusResponse{
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
},
}
sc := &fakeScanner{procs: nil}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateRunning {
t.Errorf("State = %v, want stateRunning", s.State)
}
if s.PID != 12345 {
t.Errorf("PID = %d, want 12345", s.PID)
}
if s.UptimeSec != 150 {
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
}
}
func TestDeriveStatuses_OrphanBus(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: []busdiscover.Process{
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
}}
now := time.Now()
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateOrphan {
t.Errorf("State = %v, want stateOrphan", s.State)
}
if s.PID != 70926 {
t.Errorf("PID = %d, want 70926", s.PID)
}
wantUptime := int((19 * time.Hour).Seconds())
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
}
}
func TestDeriveStatuses_NotRunning(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: nil}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateNotRunning {
t.Errorf("State = %v, want stateNotRunning", s.State)
}
}
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: []busdiscover.Process{
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
}}
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
if len(statuses) != 2 {
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
}
byID := map[string]appStatus{}
for _, s := range statuses {
byID[s.AppID] = s
}
if byID["cli_known"].State != stateNotRunning {
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
}
if byID["cli_orphan"].State != stateOrphan {
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
}
}
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
q := &fakeBusQuerier{
respByAppID: map[string]*protocol.StatusResponse{
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
},
}
sc := &fakeScanner{err: errors.New("ps failed")}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
if statuses[0].State != stateRunning {
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
}
}
func TestWriteStatusText_OrphanBlock(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_XXXXXXXXXXXXXXXX",
State: stateOrphan,
PID: 70926,
UptimeSec: 68400,
}}
writeStatusText(&buf, statuses)
out := buf.String()
for _, want := range []string{
"── cli_XXXXXXXXXXXXXXXX ──",
"Bus: orphan (PID 70926, started 19h ago)",
"Issue: socket file missing — consumers cannot connect",
"Action: kill 70926",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\nfull output:\n%s", want, out)
}
}
if strings.Contains(out, "running (PID") {
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
}
}
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_XXXXXXXXXXXXXXXX",
State: stateOrphan,
PID: 70926,
UptimeSec: 68400,
}}
if err := writeStatusJSON(&buf, statuses); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
var payload struct {
Apps []map[string]interface{} `json:"apps"`
}
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(payload.Apps) != 1 {
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
}
a := payload.Apps[0]
if a["status"] != "orphan" {
t.Errorf("status = %v, want \"orphan\"", a["status"])
}
if a["running"] != false {
t.Errorf("running = %v, want false", a["running"])
}
if a["issue"] != "socket file missing" {
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
}
if a["suggested_action"] != "kill 70926" {
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
}
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
t.Errorf("pid = %v, want 70926", a["pid"])
}
}
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_running",
State: stateRunning,
PID: 11111,
UptimeSec: 60,
Active: 0,
}}
if err := writeStatusJSON(&buf, statuses); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
out := buf.String()
if strings.Contains(out, `"issue"`) {
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
}
if strings.Contains(out, `"suggested_action"`) {
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
}
}
func TestHumanizeDuration(t *testing.T) {
for _, tt := range []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s ago"},
{90 * time.Second, "1m ago"},
{45 * time.Minute, "45m ago"},
{90 * time.Minute, "1h ago"},
{5 * time.Hour, "5h ago"},
{30 * time.Hour, "1d ago"},
{80 * time.Hour, "3d ago"},
} {
got := humanizeDuration(tt.d)
if got != tt.want {
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}

View File

@@ -1,241 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"fmt"
"io"
"os"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
type stopStatus string
const (
stopStopped stopStatus = "stopped"
stopNoBus stopStatus = "no_bus"
stopRefused stopStatus = "refused"
stopErrored stopStatus = "error"
)
type stopResult struct {
AppID string `json:"app_id"`
Status stopStatus `json:"status"`
PID int `json:"pid,omitempty"`
Reason string `json:"reason,omitempty"`
}
type stopCmdOpts struct {
appID string
all bool
force bool
asJSON bool
}
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
var o stopCmdOpts
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the event bus daemon",
Long: `Stop the event bus daemon. Target is one of:
• the current profile's AppID (default)
• an explicit AppID via --app-id
• every running bus on this machine via --all
Exit code: 2 if any target was refused or errored, 0 otherwise.
--force widens two gates:
1. Allows stopping a bus that still has active consumers.
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
process and cleans up the stale socket instead of returning an
error.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStop(f, o)
},
}
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
return cmd
}
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
tr := transport.New()
var targets []string
if o.all {
targets = discoverAppIDs()
} else {
targetAppID := o.appID
if targetAppID == "" {
cfg, err := f.Config()
if err != nil {
return err
}
targetAppID = cfg.AppID
}
targets = []string{targetAppID}
}
if len(targets) == 0 {
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, nil)
}
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
return nil
}
results := make([]stopResult, 0, len(targets))
for _, id := range targets {
results = append(results, stopBusOne(tr, id, o.force))
}
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, results)
}
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
for _, r := range results {
if r.Status == stopRefused || r.Status == stopErrored {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
resp, err := busctl.QueryStatus(tr, appID)
if err != nil {
return stopResult{AppID: appID, Status: stopNoBus}
}
if resp.ActiveConns > 0 && !force {
pids := make([]int, len(resp.Consumers))
for i, c := range resp.Consumers {
pids[i] = c.PID
}
return stopResult{
AppID: appID,
Status: stopRefused,
PID: resp.PID,
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
}
}
if err := busctl.SendShutdown(tr, appID); err != nil {
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
}
const pollInterval = 100 * time.Millisecond
deadline := time.Now().Add(shutdownBudget)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
probe, dialErr := tr.Dial(tr.Address(appID))
if dialErr != nil {
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
}
probe.Close()
}
if !force {
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
}
}
// --force: SIGKILL and clean up the stale socket.
if err := killProcess(resp.PID); err != nil {
if errors.Is(err, os.ErrProcessDone) {
// Bus exited between timeout and kill — treat as success.
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "bus exited during kill attempt",
}
}
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
}
}
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "killed (ungraceful) after shutdown timeout",
}
}
// killProcess is a var so tests can swap it without spawning sub-processes.
var killProcess = func(pid int) error {
p, err := os.FindProcess(pid)
if err != nil {
return err
}
return p.Kill()
}
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
var shutdownBudget = 5 * time.Second
func writeStopJSON(w io.Writer, results []stopResult) error {
if results == nil {
results = []stopResult{}
}
output.PrintJson(w, map[string]interface{}{"results": results})
return nil
}
func writeStopText(out, errOut io.Writer, results []stopResult) {
for _, r := range results {
switch r.Status {
case stopStopped:
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
case stopNoBus:
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
case stopRefused:
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
case stopErrored:
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
}
}
}
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
func discoverAppIDs() []string {
procs, err := busdiscover.Default().ScanBusProcesses()
if err != nil {
return nil
}
ids := make([]string, 0, len(procs))
for _, p := range procs {
ids = append(ids, p.AppID)
}
return ids
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"sort"
"testing"
"github.com/larksuite/cli/internal/event/busdiscover"
)
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
eventsDir := filepath.Join(tmp, "events")
// Two live buses (lock held until t.Cleanup releases it).
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
appDir := filepath.Join(eventsDir, app)
h, err := busdiscover.WritePIDFile(appDir, 1234)
if err != nil {
t.Fatalf("WritePIDFile %s: %v", app, err)
}
t.Cleanup(func() { _ = h.Release() })
}
// Dead bus: lock acquired then released → looks like a stale dir on disk.
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
if err != nil {
t.Fatalf("WritePIDFile dead: %v", err)
}
if err := hDead.Release(); err != nil {
t.Fatalf("Release dead: %v", err)
}
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
t.Fatal(err)
}
// Stray non-dir file under events/.
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
t.Fatal(err)
}
got := discoverAppIDs()
sort.Strings(got)
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
if len(got) != len(want) {
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if got := discoverAppIDs(); len(got) != 0 {
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
}
}

View File

@@ -1,340 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bufio"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
type mockTransport struct {
mu sync.Mutex
addr string
cleaned bool
}
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
return net.Listen("tcp", addr)
}
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
}
func (t *mockTransport) Address(appID string) string {
t.mu.Lock()
defer t.mu.Unlock()
return t.addr
}
func (t *mockTransport) Cleanup(addr string) {
t.mu.Lock()
t.cleaned = true
t.mu.Unlock()
}
func (t *mockTransport) didCleanup() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.cleaned
}
type fakeBus struct {
listener net.Listener
pid int
exitDelay time.Duration
unresponsive bool
shutdownCount int32
wg sync.WaitGroup
stopOnce sync.Once
done chan struct{}
}
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
b := &fakeBus{
listener: ln,
pid: pid,
exitDelay: exitDelay,
unresponsive: unresponsive,
done: make(chan struct{}),
}
b.wg.Add(1)
go b.serve()
return b
}
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
func (b *fakeBus) serve() {
defer b.wg.Done()
for {
conn, err := b.listener.Accept()
if err != nil {
return
}
b.wg.Add(1)
go b.handle(conn)
}
}
func (b *fakeBus) handle(conn net.Conn) {
defer b.wg.Done()
defer conn.Close()
r := bufio.NewReader(conn)
line, err := r.ReadBytes('\n')
if err != nil {
return
}
msg, err := protocol.Decode(line)
if err != nil {
return
}
switch msg.(type) {
case *protocol.StatusQuery:
_ = protocol.Encode(conn, &protocol.StatusResponse{
Type: protocol.MsgTypeStatusResponse,
PID: b.pid,
UptimeSec: 1,
ActiveConns: 0,
Consumers: nil,
})
case *protocol.Shutdown:
atomic.AddInt32(&b.shutdownCount, 1)
if b.unresponsive {
return
}
if b.exitDelay > 0 {
go func() {
time.Sleep(b.exitDelay)
b.stop()
}()
} else {
go b.stop()
}
}
}
func (b *fakeBus) stop() {
b.stopOnce.Do(func() {
_ = b.listener.Close()
close(b.done)
})
}
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
b.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(budget):
t.Fatalf("fakeBus did not shut down within %v", budget)
}
}
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
const pid = 44441
const exitDelay = 500 * time.Millisecond
bus := newFakeBus(t, pid, exitDelay, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Fatalf("pid = %d; want %d", res.PID, pid)
}
if elapsed < 400*time.Millisecond {
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
}
if elapsed > 3*time.Second {
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
}
bus.wait(t, 2*time.Second)
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
}
}
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
const pid = 44442
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "error" {
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "did not exit within") {
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 0 {
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
}
if tr.didCleanup() {
t.Errorf("Cleanup should not be called when --force is false")
}
}
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
const pid = 44443
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", true)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "killed") {
t.Errorf("reason %q should mention 'killed'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
}
if !tr.didCleanup() {
t.Errorf("Cleanup was not invoked after force-kill")
}
}
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
const pid = 12345
bus := newFakeBus(t, pid, 0, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("expected PID=%d, got %d", pid, res.PID)
}
if elapsed > 500*time.Millisecond {
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
}
}
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
const pid = 99999
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return os.ErrProcessDone
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
res := stopBusOne(tr, "test-app", true)
if res.Status != "stopped" {
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
}
killMu.Lock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
}
killMu.Unlock()
if !tr.didCleanup() {
t.Error("expected Cleanup to be called even when kill reported already-dead")
}
if !strings.Contains(res.Reason, "exited during kill attempt") {
t.Errorf("expected reason about race, got %q", res.Reason)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"sort"
"strings"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
const maxSuggestions = 3
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
func suggestEventKeys(input string) []string {
type match struct {
key string
dist int
}
var hits []match
threshold := max(2, len(input)/5)
for _, def := range eventlib.ListAll() {
if strings.Contains(def.Key, input) {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
n := min(maxSuggestions, len(hits))
out := make([]string, n)
for i := range out {
out[i] = hits[i].key
}
return out
}
// formatSuggestions renders keys as a human-readable quoted tail.
func formatSuggestions(keys []string) string {
if len(keys) == 0 {
return ""
}
quoted := make([]string, len(keys))
for i, k := range keys {
quoted[i] = fmt.Sprintf("%q", k)
}
if len(quoted) == 1 {
return quoted[0]
}
return "one of: " + strings.Join(quoted, ", ")
}
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
func unknownEventKeyErr(key string) error {
msg := fmt.Sprintf("unknown EventKey: %s", key)
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -1,150 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"strings"
"testing"
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string
input string
wantEmpty bool
wantAllHavePrefix string
wantContains string
}{
{
name: "typo via Levenshtein (recieve → receive)",
input: "im.message.recieve_v1",
wantContains: "im.message.receive_v1",
},
{
name: "substring match returns im.message.* keys",
input: "im.message",
wantAllHavePrefix: "im.message.",
},
{
name: "completely unrelated input returns empty",
input: "xyzzy_no_such_event_key_at_all",
wantEmpty: true,
},
{
name: "exact key is a substring of itself",
input: "im.message.receive_v1",
wantContains: "im.message.receive_v1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := suggestEventKeys(tc.input)
if tc.wantEmpty {
if len(got) != 0 {
t.Errorf("expected empty slice, got %v", got)
}
return
}
if len(got) == 0 {
t.Fatalf("expected non-empty suggestions, got nothing")
}
if len(got) > maxSuggestions {
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
}
if tc.wantAllHavePrefix != "" {
for _, k := range got {
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
}
}
}
if tc.wantContains != "" {
found := false
for _, k := range got {
if k == tc.wantContains {
found = true
break
}
}
if !found {
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
}
}
})
}
}
func TestFormatSuggestions(t *testing.T) {
cases := []struct {
name string
in []string
want string
}{
{name: "empty → empty string", in: nil, want: ""},
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := formatSuggestions(tc.in); got != tc.want {
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
err := unknownEventKeyErr("im.message.recieve_v1")
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
for _, want := range []string{
"unknown EventKey: im.message.recieve_v1",
"did you mean",
"im.message.receive_v1",
} {
if !strings.Contains(msg, want) {
t.Errorf("error %q missing %q", msg, want)
}
}
}
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if !strings.Contains(msg, "unknown EventKey") {
t.Errorf("error should mention unknown EventKey: %q", msg)
}
if strings.Contains(msg, "did you mean") {
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
}
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"io"
)
// tableWidths returns the max cell width per column across headers + rows.
func tableWidths(headers []string, rows [][]string) []int {
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
break
}
if l := len(cell); l > widths[i] {
widths[i] = l
}
}
}
return widths
}
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
for i, cell := range cells {
if i == len(cells)-1 {
fmt.Fprintln(out, cell)
return
}
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
}
}

View File

@@ -3,38 +3,15 @@
package cmd
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
import "github.com/spf13/pflag"
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
// is a build-time policy — when true, --profile stays parseable but is marked
// hidden from help and shell completion.
// actual Cobra command tree.
type GlobalOptions struct {
Profile string
HideProfile bool
Profile string
}
// RegisterGlobalFlags registers the root-level persistent flags on fs and
// applies any visibility policy encoded in opts. Pure function: no disk,
// network, or environment reads — the caller decides HideProfile.
// RegisterGlobalFlags registers the root-level persistent flags.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
if opts.HideProfile {
_ = fs.MarkHidden("profile")
}
}
// isSingleAppMode reports whether the on-disk config has at most one app.
// Missing configs are treated as single-app since --profile is meaningless
// until at least two profiles exist. Intended for the Execute entry point —
// buildInternal must not call this directly to stay state-free.
func isSingleAppMode() bool {
raw, err := core.LoadMultiAppConfig()
if err != nil || raw == nil {
return true
}
return len(raw.Apps) <= 1
}

View File

@@ -1,110 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if flag.Hidden {
t.Fatal("profile flag should be visible when HideProfile is false")
}
}
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{HideProfile: true}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile is true")
}
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
}
if opts.Profile != "x" {
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
}
}
func TestIsSingleAppMode_NoConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true when no config exists")
}
}
func TestIsSingleAppMode_SingleApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
})
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true for single-app config")
}
}
func TestIsSingleAppMode_MultiApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
})
if isSingleAppMode() {
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
}
}
func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
}
}
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered by default")
}
if flag.Hidden {
t.Fatal("profile flag should be visible by default")
}
}
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "github.com/larksuite/cli/internal/vfs"
// SetDefaultFS replaces the global filesystem implementation used by internal
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
// the default OS filesystem is restored.
//
// Call this before Build or Execute to take effect.
func SetDefaultFS(fs vfs.FS) {
if fs == nil {
fs = vfs.OsFs{}
}
vfs.DefaultFS = fs
}

View File

@@ -14,6 +14,14 @@ import (
"os"
"strconv"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
@@ -21,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -85,13 +94,37 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
configureFlagCompletions(os.Args)
f := cmdutil.NewDefault(inv)
f, rootCmd := buildInternal(
context.Background(), inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
@@ -155,12 +188,6 @@ func isCompletionCommand(args []string) bool {
return false
}
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
@@ -248,29 +275,16 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
// when a command has tips set via cmdutil.SetTips.
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == root {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
f.Hidden = false
defer func() { f.Hidden = true }()
}
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {
fmt.Fprintln(out)
fmt.Fprintln(out, "Risk:", level)
}
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {
return
}
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range tips {

View File

@@ -135,12 +135,10 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f := cmdutil.NewDefault(
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
cmdutil.InvocationContext{Profile: profile},
)
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
return f, stdout, stderr
}
@@ -149,6 +147,20 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
stderr.Reset()
}
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
out := stdout.String()
const prefix = "=== Dry Run ===\n"
if !strings.HasPrefix(out, prefix) {
t.Fatalf("expected dry-run prefix, got:\n%s", out)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
}
return payload
}
// --- api command ---
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
@@ -388,25 +400,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
}
}
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -414,14 +408,16 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -441,7 +437,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -449,14 +445,16 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
}
// --- shortcut command ---

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// rendersHelp runs the wrapped help func and returns stdout.
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
t.Helper()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
return buf.String()
}
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
root.AddCommand(child)
out := rendersHelp(t, child)
if !strings.Contains(out, "Risk: high-risk-write") {
t.Errorf("expected Risk line in help output, got:\n%s", out)
}
}
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
out := rendersHelp(t, child)
if strings.Contains(out, "Risk:") {
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
}
}
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
cmdutil.SetTips(child, []string{"use --yes to confirm"})
root.AddCommand(child)
out := rendersHelp(t, child)
riskIdx := strings.Index(out, "Risk:")
tipsIdx := strings.Index(out, "Tips:")
if riskIdx == -1 || tipsIdx == -1 {
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
}
if riskIdx >= tipsIdx {
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
}
}

View File

@@ -196,28 +196,3 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
}
})
}
}

View File

@@ -4,14 +4,12 @@
package schema
import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
@@ -21,7 +19,6 @@ import (
// SchemaOptions holds all inputs for the schema command.
type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string
@@ -44,7 +41,7 @@ func printServices(w io.Writer) {
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
func printResourceList(w io.Writer, spec map[string]interface{}) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
@@ -58,13 +55,9 @@ func printResourceList(w io.Writer, spec map[string]interface{}, mode core.Stric
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -80,12 +73,6 @@ func printResourceList(w io.Writer, spec map[string]interface{}, mode core.Stric
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
@@ -93,7 +80,6 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
@@ -152,25 +138,11 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
@@ -212,13 +184,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
}
// CLI example
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
@@ -366,7 +332,6 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
if len(args) > 0 {
opts.Path = args[0]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
@@ -375,9 +340,9 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.ValidArgsFunction = completeSchemaPath
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -387,86 +352,78 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// afterService = everything user typed after "serviceName."
afterService := strings.Join(parts[1:], ".")
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
// afterService is a prefix of this resource name → resource candidate
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
} else if strings.HasPrefix(afterService, resName+".") {
// This resource is fully matched; remainder is method prefix
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
}
sort.Strings(completions)
return completions
// If all completions end with ".", user is still navigating resources → NoSpace
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
@@ -485,9 +442,9 @@ func schemaRun(opts *SchemaOptions) error {
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec, mode)
printResourceList(out, spec)
} else {
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
output.PrintJson(out, spec)
}
return nil
}
@@ -508,7 +465,6 @@ func schemaRun(opts *SchemaOptions) error {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -517,26 +473,13 @@ func schemaRun(opts *SchemaOptions) error {
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
output.PrintJson(out, resource)
}
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
@@ -555,67 +498,3 @@ func schemaRun(opts *SchemaOptions) error {
}
return nil
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
}

View File

@@ -4,7 +4,6 @@
package schema
import (
"bytes"
"strings"
"testing"
@@ -62,169 +61,3 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
}
}
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
}
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
}
}
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}

View File

@@ -5,6 +5,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -24,10 +25,6 @@ import (
// RegisterServiceCommands registers all service commands from from_meta specs.
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsWithContext(context.Background(), parent, f)
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
@@ -42,15 +39,11 @@ func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Comma
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerService(parent, spec, resources, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
@@ -78,11 +71,11 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
registerResource(svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
@@ -95,7 +88,7 @@ func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spe
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
registerMethod(res, spec, methodMap, methodName, name, f)
}
}
@@ -109,38 +102,26 @@ 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
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
@@ -167,12 +148,12 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
@@ -180,25 +161,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
}
@@ -242,24 +213,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
}
request, fileMeta, err := buildServiceRequest(opts)
request, err := buildServiceRequest(opts)
if err != nil {
return err
}
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return serviceDryRun(f, request, config, opts.Format)
}
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
}
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
return err
@@ -283,14 +245,13 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return output.ErrNetwork("API call failed: %s", err)
}
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
CheckError: checkErr,
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CheckError: checkErr,
})
}
@@ -343,28 +304,19 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
return client.RawApiRequest{}, nil, err
}
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -377,13 +329,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required path parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
return client.RawApiRequest{}, output.ErrValidation("%s", err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
@@ -399,7 +351,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required query parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
@@ -413,60 +365,22 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
if err != nil {
return client.RawApiRequest{}, err
}
request := client.RawApiRequest{
Method: httpMethod,
URL: url,
Params: queryParams,
Data: data,
As: opts.As,
}
if opts.File != "" {
// File upload: determine default field name from metadata.
defaultField := "file"
if len(opts.FileFields) == 1 {
defaultField = opts.FileFields[0]
}
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
if opts.DryRun {
return request, &cmdutil.FileUploadMeta{
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
}, nil
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = data
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
return request, nil, nil
return request, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {

View File

@@ -1,114 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
"parameters": map[string]interface{}{
"file_token": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
},
}
}
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
"file_token": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
},
}
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
if cmd.Flags().Lookup("yes") == nil {
t.Error("expected --yes flag registered for risk=high-risk-write")
}
}
func TestServiceMethod_YesFlagNotRegisteredForWrite(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
if cmd.Flags().Lookup("yes") != nil {
t.Error("expected --yes flag NOT registered when risk is unset")
}
}
func TestServiceMethod_RiskAnnotationSet(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
level, ok := cmdutil.GetRisk(cmd)
if !ok {
t.Fatal("expected Risk annotation to be set")
}
if level != "high-risk-write" {
t.Errorf("level = %q, want high-risk-write", level)
}
}
func TestServiceMethod_RiskAnnotationAbsentForUnsetRisk(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
if _, ok := cmdutil.GetRisk(cmd); ok {
t.Error("expected no Risk annotation when meta risk is unset")
}
}
func TestServiceMethod_GateBlocksWithoutYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
// --as bot skips the scope check so we reach the gate without external creds.
cmd.SetArgs([]string{"--as", "bot", "--params", `{"file_token":"tok_abc"}`})
err := cmd.Execute()
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "requires confirmation") {
t.Errorf("expected 'requires confirmation' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "drive.files.delete") {
t.Errorf("expected schema path in error action, got: %v", err)
}
}
func TestServiceMethod_DryRunBypassesGate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
cmd.SetArgs([]string{
"--as", "bot",
"--params", `{"file_token":"tok_abc"}`,
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run should not hit confirmation gate; got: %v", err)
}
if !strings.Contains(stdout.String(), "files/tok_abc") {
t.Errorf("expected dry-run output to contain URL, got:\n%s", stdout.String())
}
}

View File

@@ -4,7 +4,6 @@
package service
import (
"os"
"strings"
"testing"
@@ -121,24 +120,6 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
}
}
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
// ── NewCmdServiceMethod flags ──
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
@@ -327,7 +308,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid format") {
if !strings.Contains(err.Error(), "--params invalid JSON format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -350,24 +331,6 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
}
}
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
@@ -729,144 +692,6 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
}
func imSpec() map[string]interface{} {
return map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag == nil {
t.Fatal("expected --file flag to be registered for file upload method")
}
}
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for non-file method")
}
}
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
getMethod := map[string]interface{}{
"path": "images",
"httpMethod": "GET",
"requestBody": map[string]interface{}{
"image": map[string]interface{}{
"type": "file",
},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
}
}
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.jpg"
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{
"--file", "image=" + tmpFile,
"--data", `{"image_type":"message"}`,
"--dry-run",
"--as", "bot",
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}
func TestDetectFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
want []string
}{
{
name: "single file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
want: []string{"image"},
},
{
name: "no file fields",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
want: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
// ── helpers ──
func isExitError(err error, target **output.ExitError) bool {

View File

@@ -1,314 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"fmt"
"runtime"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/update"
)
const (
repoURL = "https://github.com/larksuite/cli"
maxNpmOutput = 2000
osWindows = "windows"
)
// Overridable for testing.
var (
fetchLatest = func() (string, error) { return update.FetchLatest() }
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
)
func isWindows() bool { return currentOS == osWindows }
func releaseURL(version string) string {
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
}
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
// --- Terminal symbols (ASCII fallback on Windows) ---
func symOK() string {
if isWindows() {
return "[OK]"
}
return "✓"
}
func symFail() string {
if isWindows() {
return "[FAIL]"
}
return "✗"
}
func symWarn() string {
if isWindows() {
return "[WARN]"
}
return "⚠"
}
func symArrow() string {
if isWindows() {
return "->"
}
return "→"
}
// --- Command ---
// UpdateOptions holds inputs for the update command.
type UpdateOptions struct {
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
}
// NewCmdUpdate creates the update command.
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
opts := &UpdateOptions{Factory: f}
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
return cmd
}
func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
updater.CleanupStaleFiles()
output.PendingNotice = nil
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
}
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
})
return nil
}
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
return nil
}
// 4. Detect installation method
detect := updater.DetectInstallMethod()
// 5. --check
if opts.Check {
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
}
// 6. Execute update
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect)
}
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
})
return output.ErrBare(exitCode)
}
return output.Errorf(exitCode, errType, "%s", msg)
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "update_available",
"auto_update": canAutoUpdate,
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if canAutoUpdate {
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
} else {
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
}
return nil
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
reason := detect.ManualReason()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "latest_version": latest,
"action": "manual_required",
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
return nil
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
}
if npmResult.Stdout.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
}
if npmResult.Stderr.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
}
// Verify the new binary is functional before proceeding.
// If corrupt, restore the previous version from .old.
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
})
return output.ErrBare(output.ExitAPI)
}
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
fmt.Fprintf(io.ErrOut, " %s\n", hint)
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort).
skillsResult := updater.RunSkillsUpdate()
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
if skillsResult.Err != nil {
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
}
output.PrintJson(io.Out, result)
return nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
if skillsResult.Err != nil {
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
} else {
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
return nil
}
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

View File

@@ -1,851 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
)
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.SkillsUpdateOverride = skillsFn
u.VerifyOverride = func(string) error { return nil }
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "already_up_to_date"`) {
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
}
if !strings.Contains(out, `"ok": true`) {
t.Errorf("expected ok:true in JSON output, got: %s", out)
}
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "already up to date") {
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
}
}
func TestUpdateManual_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "manual_required"`) {
t.Errorf("expected manual_required in output, got: %s", out)
}
if !strings.Contains(out, "not installed via npm") {
t.Errorf("expected accurate reason in output, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned URL in output, got: %s", out)
}
}
func TestUpdateManual_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "not installed via npm") {
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
}
}
func TestUpdateNpm_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdateNpm_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--force", "--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in JSON output, got: %s", out)
}
}
func TestUpdateFetchError_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
defer func() { fetchLatest = origFetch }()
err := cmd.Execute()
// cobra silences errors when RunE returns; we just check stdout
_ = err
out := stdout.String()
if !strings.Contains(out, `"ok": false`) {
t.Errorf("expected ok:false in JSON output, got: %s", out)
}
if !strings.Contains(out, "network timeout") {
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
}
}
func TestUpdateFetchError_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
defer func() { fetchLatest = origFetch }()
// Suppress cobra's default error printing.
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
}
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
_ = cmd.Execute()
out := stdout.String()
if !strings.Contains(out, "invalid version") {
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
}
}
func TestUpdateDevVersion_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "DEV" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in JSON output, got: %s", out)
}
}
func TestUpdateNpmFail_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
r.Err = errors.New("npm install failed")
return r
}
return u
}
defer func() { newUpdater = origNew }()
_ = cmd.Execute()
out := stdout.String()
if !strings.Contains(out, "permission denied") {
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
}
if !strings.Contains(out, `"hint"`) {
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
}
}
func TestUpdateNpmFail_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
r.Err = errors.New("npm install failed")
return r
}
return u
}
defer func() { newUpdater = origNew }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
_ = cmd.Execute()
out := stderr.String()
if !strings.Contains(out, "Update failed") {
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
}
if !strings.Contains(out, "Permission denied") {
t.Errorf("expected permission hint in stderr, got: %s", out)
}
}
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
return nil
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err == nil {
t.Fatal("expected verification failure")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
}
out := stdout.String()
if !strings.Contains(out, "automatic rollback is unavailable") {
t.Errorf("expected unavailable rollback hint, got: %s", out)
}
if strings.Contains(out, "previous version has been restored") {
t.Errorf("should not claim restore when no backup is available, got: %s", out)
}
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
t.Errorf("expected manual reinstall command in hint, got: %s", out)
}
}
func TestUpdateCheck_JSON_Npm(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "update_available"`) {
t.Errorf("expected update_available action, got: %s", out)
}
if !strings.Contains(out, `"auto_update": true`) {
t.Errorf("expected auto_update:true for npm, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned release URL, got: %s", out)
}
if !strings.Contains(out, "CHANGELOG") {
t.Errorf("expected changelog URL, got: %s", out)
}
}
func TestUpdateCheck_Human_Npm(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Update available") {
t.Errorf("expected 'Update available' in stderr, got: %s", out)
}
if !strings.Contains(out, "lark-cli update") {
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
}
}
func TestUpdateCheck_Human_Manual(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Update available") {
t.Errorf("expected 'Update available' in stderr, got: %s", out)
}
if !strings.Contains(out, "manually") {
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
}
if strings.Contains(out, "lark-cli update` to install") {
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
}
}
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
// npm detected (node_modules in path) but npm binary not available
mockDetect(t, selfupdate.DetectResult{
Method: selfupdate.InstallNpm,
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
NpmAvailable: false,
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "manual_required"`) {
t.Errorf("expected manual_required when npm not found, got: %s", out)
}
// Must say "npm is not available", not generic "not installed via npm"
if !strings.Contains(out, "npm is not available") {
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
}
}
func TestReleaseURL(t *testing.T) {
got := releaseURL("2.0.0")
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
t.Errorf("expected version-pinned URL, got: %s", got)
}
got2 := releaseURL("v1.5.0")
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
t.Errorf("expected no double v prefix, got: %s", got2)
}
}
func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
if strings.Contains(hint, "sudo npm install -g") {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origOS := currentOS
currentOS = osWindows
defer func() { currentOS = origOS }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
}
}
func TestUpdateWindows_Check_JSON(t *testing.T) {
// --check on Windows npm should report auto_update: true (rename trick available).
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origOS := currentOS
currentOS = osWindows
defer func() { currentOS = origOS }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"auto_update": true`) {
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
}
}
func TestUpdateWindows_Symbols(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
currentOS = "windows"
if symOK() != "[OK]" {
t.Errorf("expected [OK] on Windows, got: %s", symOK())
}
if symFail() != "[FAIL]" {
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
}
if symWarn() != "[WARN]" {
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
}
if symArrow() != "->" {
t.Errorf("expected -> on Windows, got: %s", symArrow())
}
currentOS = "darwin"
if symOK() != "\u2713" {
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
}
if symArrow() != "\u2192" {
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
}
}
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Should NOT have skills_warning when skills succeed
if strings.Contains(out, "skills_warning") {
t.Errorf("expected no skills_warning on success, got: %s", out)
}
}
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// CLI update should still succeed (ok:true)
if !strings.Contains(out, `"ok": true`) {
t.Errorf("expected ok:true despite skills failure, got: %s", out)
}
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected action:updated despite skills failure, got: %s", out)
}
// Should have skills_warning with detail
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
}
}
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
// CLI update should still show success
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected CLI success message, got: %s", out)
}
// Skills warning should be shown
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
}
}
func TestTruncate(t *testing.T) {
long := strings.Repeat("x", 3000)
got := selfupdate.Truncate(long, 2000)
if len(got) != 2000 {
t.Errorf("expected truncated length 2000, got %d", len(got))
}
short := "hello"
got2 := selfupdate.Truncate(short, 2000)
if got2 != "hello" {
t.Errorf("expected 'hello', got %q", got2)
}
}

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Message struct {
MessageID string `json:"message_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
})
}
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = msg.CreateTime
}
out := &ImMessageReceiveOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
ID: msg.MessageID,
MessageID: msg.MessageID,
CreateTime: msg.CreateTime,
ChatID: msg.ChatID,
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
Content: content,
}
return json.Marshal(out)
}

View File

@@ -1,190 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
def, ok := event.Lookup("im.message.receive_v1")
if !ok {
t.Fatal("im.message.receive_v1 should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for Processed key")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty — preflightScopes would bypass validation")
}
}
func TestIMKeys_NativeEventsRegistered(t *testing.T) {
want := []string{
"im.message.message_read_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"im.chat.member.user.added_v1",
"im.chat.member.user.withdrawn_v1",
"im.chat.member.user.deleted_v1",
"im.chat.updated_v1",
"im.chat.disbanded_v1",
}
for _, k := range want {
def, ok := event.Lookup(k)
if !ok {
t.Errorf("%s should be registered via Keys()", k)
continue
}
if def.Schema.Native == nil {
t.Errorf("%s: Schema.Native must be set for native key", k)
}
if def.Schema.Custom != nil {
t.Errorf("%s: Native key must not set Schema.Custom", k)
}
if def.Process != nil {
t.Errorf("%s: Native key must not set Process", k)
}
if def.Schema.Native != nil && def.Schema.Native.Type == nil {
t.Errorf("%s: Schema.Native.Type must reference an SDK type", k)
}
}
}
func TestProcessImMessageReceive_Text(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
}
if out.MessageID != "om_text_001" || out.ID != "om_text_001" {
t.Errorf("MessageID/ID = %q/%q", out.MessageID, out.ID)
}
if out.ChatType != "p2p" || out.ChatID != "oc_chat" {
t.Errorf("chat_id/chat_type = %q/%q", out.ChatID, out.ChatType)
}
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_card",
"event_type": "im.message.receive_v1",
"create_time": "1776409469274",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_card_001",
"chat_id": "oc_chat",
"chat_type": "group",
"message_type": "interactive",
"create_time": "1776409468987",
"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"A card\"}}}"
}
}
}`
out := runReceive(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
}
if out.MessageType != "interactive" {
t.Errorf("MessageType = %q", out.MessageType)
}
if out.ChatType != "group" {
t.Errorf("ChatType = %q", out.ChatType)
}
}
func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out ImMessageReceiveOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid ImMessageReceiveOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -1,184 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"reflect"
"github.com/larksuite/cli/internal/event/schemas"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
// nativeIMKey curates metadata for a Native IM event; fieldOverrides paths are JSON Pointer anchored at the V2-wrapped schema (start with /event/...).
type nativeIMKey struct {
key string
title string
description string
scopes []string
bodyType reflect.Type
fieldOverrides map[string]schemas.FieldMeta
}
// userIDOv returns open_id/union_id/user_id overrides for a UserID object at prefix.
func userIDOv(prefix string) map[string]schemas.FieldMeta {
return map[string]schemas.FieldMeta{
prefix + "/open_id": {Kind: "open_id"},
prefix + "/union_id": {Kind: "union_id"},
prefix + "/user_id": {Kind: "user_id"},
}
}
// mergeOv merges FieldMeta maps left-to-right (later wins).
func mergeOv(ms ...map[string]schemas.FieldMeta) map[string]schemas.FieldMeta {
out := map[string]schemas.FieldMeta{}
for _, m := range ms {
for k, v := range m {
out[k] = v
}
}
return out
}
var nativeIMKeys = []nativeIMKey{
{
key: "im.message.message_read_v1",
title: "Message read",
description: "Triggered after a user reads a P2P message sent by the bot",
scopes: []string{"im:message:readonly", "im:message"},
bodyType: reflect.TypeOf(larkim.P2MessageReadV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/reader/reader_id"),
map[string]schemas.FieldMeta{
"/event/reader/read_time": {Kind: "timestamp_ms"},
"/event/message_id_list/*": {Kind: "message_id"},
},
),
},
{
key: "im.message.reaction.created_v1",
title: "Reaction added",
description: "Triggered when a reaction is added to a message",
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
bodyType: reflect.TypeOf(larkim.P2MessageReactionCreatedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/user_id"),
map[string]schemas.FieldMeta{
"/event/message_id": {Kind: "message_id"},
"/event/action_time": {Kind: "timestamp_ms"},
},
),
},
{
key: "im.message.reaction.deleted_v1",
title: "Reaction removed",
description: "Triggered when a reaction is removed from a message",
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
bodyType: reflect.TypeOf(larkim.P2MessageReactionDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/user_id"),
map[string]schemas.FieldMeta{
"/event/message_id": {Kind: "message_id"},
"/event/action_time": {Kind: "timestamp_ms"},
},
),
},
{
key: "im.chat.member.bot.added_v1",
title: "Bot added to chat",
description: "Triggered when the bot is added to a chat",
scopes: []string{"im:chat.members:bot_access"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotAddedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.bot.deleted_v1",
title: "Bot removed from chat",
description: "Triggered after the bot is removed from a chat",
scopes: []string{"im:chat.members:bot_access"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.added_v1",
title: "User added to chat",
description: "Triggered when a new user joins a chat (including topic chats)",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserAddedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.withdrawn_v1",
title: "User invite withdrawn",
description: "Triggered after a pending user invite is withdrawn",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserWithdrawnV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.deleted_v1",
title: "User left chat",
description: "Triggered when a user leaves or is removed from a chat",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.updated_v1",
title: "Chat updated",
description: "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
scopes: []string{"im:chat:read"},
bodyType: reflect.TypeOf(larkim.P2ChatUpdatedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/before_change/owner_id"),
userIDOv("/event/after_change/owner_id"),
userIDOv("/event/moderator_list/added_member_list/*/user_id"),
userIDOv("/event/moderator_list/removed_member_list/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.disbanded_v1",
title: "Chat disbanded",
description: "Triggered after a chat is disbanded",
scopes: []string{"im:chat:read"},
bodyType: reflect.TypeOf(larkim.P2ChatDisbandedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package im registers IM-domain EventKeys.
package im
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
// Keys returns all IM-domain EventKey definitions.
func Keys() []event.KeyDefinition {
out := []event.KeyDefinition{
{
Key: "im.message.receive_v1",
DisplayName: "Receive message",
Description: "Receive IM messages",
EventType: "im.message.receive_v1",
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ImMessageReceiveOutput{})},
},
Process: processImMessageReceive,
// Narrowest grant; kept single-element since MissingScopes uses AND semantics.
Scopes: []string{"im:message.p2p_msg:readonly"},
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
}
for _, rk := range nativeIMKeys {
out = append(out, event.KeyDefinition{
Key: rk.key,
DisplayName: rk.title,
Description: rk.description,
EventType: rk.key,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: rk.bodyType},
FieldOverrides: rk.fieldOverrides,
},
Scopes: rk.scopes,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{rk.key},
})
}
return out
}

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
for _, def := range event.ListAll() {
if len(def.Schema.FieldOverrides) == 0 {
continue
}
raw := renderDefSchemaForLint(t, def)
if raw == nil {
t.Errorf("%s: FieldOverrides set but Schema has no Native/Custom spec", def.Key)
continue
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Errorf("%s: parse schema: %v", def.Key, err)
continue
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
if len(orphans) > 0 {
t.Errorf("%s: orphan FieldOverrides paths (typo or SDK drift): %v", def.Key, orphans)
}
}
}
func renderDefSchemaForLint(t *testing.T, def *event.KeyDefinition) json.RawMessage {
t.Helper()
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil
}
raw := renderSpec(t, spec)
if raw == nil {
return nil
}
if isNative {
raw = schemas.WrapV2Envelope(raw)
}
return raw
}
func pickSpec(s event.SchemaDef) (*event.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
func renderSpec(t *testing.T, s *event.SchemaSpec) json.RawMessage {
t.Helper()
if s.Type != nil {
return schemas.FromType(s.Type)
}
if len(s.Raw) > 0 {
return append(json.RawMessage{}, s.Raw...)
}
return nil
}
// Proves the pipeline catches orphan FieldOverrides paths, so TestAllKeys_FieldOverridePointersResolve isn't vacuous.
func TestOrphanDetectionMechanism(t *testing.T) {
type synthetic struct {
ValidField string `json:"valid_field"`
}
spec := &event.SchemaSpec{Type: reflect.TypeOf(synthetic{})}
raw := renderSpec(t, spec)
if raw == nil {
t.Fatal("renderSpec returned nil for synthetic type")
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
overrides := map[string]schemas.FieldMeta{
"/valid_field": {Kind: "open_id"},
"/broken_typo": {Kind: "chat_id"},
"/valid_field/x": {Kind: "email"},
}
orphans := schemas.ApplyFieldOverrides(parsed, overrides)
wantOrphans := map[string]bool{"/broken_typo": true, "/valid_field/x": true}
if len(orphans) != len(wantOrphans) {
t.Fatalf("orphans = %v, want exactly %v", orphans, wantOrphans)
}
for _, o := range orphans {
if !wantOrphans[o] {
t.Errorf("unexpected orphan %q", o)
}
}
vf := parsed["properties"].(map[string]interface{})["valid_field"].(map[string]interface{})
if vf["format"] != "open_id" {
t.Errorf("valid path not applied: %v", vf)
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted: only IM is wired up this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
}
for _, keys := range all {
for _, k := range keys {
event.RegisterKey(k)
}
}
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contentsafety
import "sync"
var (
mu sync.Mutex
provider Provider
)
// Register installs a content-safety Provider. Later registrations
// override earlier ones (last-write-wins).
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
provider = p
}
// GetProvider returns the currently registered Provider.
// Returns nil if no provider has been registered.
func GetProvider() Provider {
mu.Lock()
defer mu.Unlock()
return provider
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contentsafety
import (
"context"
"io"
)
// Provider scans parsed response data for content-safety issues.
// Implementations must be safe for concurrent use.
type Provider interface {
Name() string
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
}
// ScanRequest carries the data to scan.
type ScanRequest struct {
Path string // normalized command path (e.g. "im.messages_search")
Data any // parsed response data (generic JSON shape)
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
}
// Alert holds the result of a content-safety scan that detected issues.
type Alert struct {
Provider string `json:"provider"`
MatchedRules []string `json:"matched_rules"`
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contentsafety
import (
"context"
"io"
"testing"
)
func TestAlertFields(t *testing.T) {
a := &Alert{
Provider: "regex",
MatchedRules: []string{"rule_a", "rule_b"},
}
if a.Provider != "regex" {
t.Errorf("Provider = %q, want %q", a.Provider, "regex")
}
if len(a.MatchedRules) != 2 {
t.Errorf("MatchedRules length = %d, want 2", len(a.MatchedRules))
}
}
type stubProvider struct{}
func (s *stubProvider) Name() string { return "stub" }
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
}
func TestProviderInterface(t *testing.T) {
var p Provider = &stubProvider{}
if p.Name() != "stub" {
t.Errorf("Name() = %q, want %q", p.Name(), "stub")
}
alert, err := p.Scan(context.Background(), ScanRequest{Path: "test", Data: nil, ErrOut: io.Discard})
if err != nil {
t.Fatalf("Scan() error = %v", err)
}
if alert.Provider != "stub" {
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
}
}
func TestRegistryLastWriteWins(t *testing.T) {
mu.Lock()
old := provider
provider = nil
mu.Unlock()
defer func() {
mu.Lock()
provider = old
mu.Unlock()
}()
if GetProvider() != nil {
t.Fatal("expected nil provider initially")
}
p1 := &stubProvider{}
Register(p1)
if GetProvider() != p1 {
t.Fatal("expected p1 after first Register")
}
p2 := &stubProvider{}
Register(p2)
if GetProvider() != p2 {
t.Fatal("expected p2 after second Register (last-write-wins)")
}
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (

View File

@@ -3,10 +3,7 @@
package credential
import (
"sort"
"sync"
)
import "sync"
var (
mu sync.Mutex
@@ -14,28 +11,12 @@ var (
)
// Register registers a credential Provider.
// Providers are consulted in priority order (lowest value first).
// Providers that implement Priority() int are sorted accordingly;
// those that do not default to priority 10.
// Providers are consulted in registration order.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
sort.SliceStable(providers, func(i, j int) bool {
return providerPriority(providers[i]) < providerPriority(providers[j])
})
}
// providerPriority returns the priority of a provider.
// If the provider implements interface{ Priority() int }, that value is used;
// otherwise 10 is returned as the default priority.
// Lower values are consulted first.
func providerPriority(p Provider) int {
if pp, ok := p.(interface{ Priority() int }); ok {
return pp.Priority()
}
return 10
}
// Providers returns all registered providers (snapshot).

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
@@ -37,32 +34,6 @@ func TestRegisterAndProviders(t *testing.T) {
}
}
type priorityProvider struct {
stubProvider
priority int
}
func (p *priorityProvider) Priority() int { return p.priority }
func TestRegister_PriorityOrder(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "env"}) // priority 10 (default)
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a noop credential provider for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
// placeholder credentials so the CLI's auth pipeline can proceed normally.
// Real tokens are never present in the sandbox; the sidecar transport
// interceptor routes requests to the trusted sidecar process instead.
package sidecar
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider is the noop credential provider for sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
func (p *Provider) Priority() int { return 0 }
// ResolveAccount returns a minimal Account when sidecar mode is active.
// The account contains AppID and Brand from environment variables, a
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off", "":
acct.SupportedIdentities = credential.SupportsAll
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return acct, nil
}
// ResolveToken returns a sentinel token whose value encodes the token type.
// The transport interceptor reads this sentinel to determine the identity
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
return nil, nil
}
var sentinel string
switch req.Type {
case credential.TokenTypeUAT:
sentinel = sidecar.SentinelUAT
case credential.TokenTypeTAT:
sentinel = sidecar.SentinelTAT
default:
return nil, nil
}
return &credential.Token{
Value: sentinel,
Scopes: "", // empty → scope pre-check is skipped
Source: "sidecar",
}, nil
}
func init() {
credential.Register(&Provider{})
}

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