mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
2 Commits
feat/exter
...
feat/sidec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52dc09af95 | ||
|
|
07da0c8090 |
33
.github/CODEOWNERS
vendored
33
.github/CODEOWNERS
vendored
@@ -1,33 +0,0 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
420
.github/workflows/ci.yml
vendored
420
.github/workflows/ci.yml
vendored
@@ -1,23 +1,17 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
@@ -54,84 +48,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
extended-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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 both editions and verify identity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go build -o /tmp/lark-cli-standard .
|
||||
go build -tags extended -o /tmp/lark-cli-extended .
|
||||
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
|
||||
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
|
||||
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
|
||||
- name: Cross-compile Extended platform-specific security code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
|
||||
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
|
||||
- name: Verify edition source isolation
|
||||
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
|
||||
- name: Run Extended tests
|
||||
run: make extended-test
|
||||
|
||||
extended-platform-security:
|
||||
needs: fast-gate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run native helper isolation and path trust tests
|
||||
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -156,7 +72,6 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -165,108 +80,10 @@ jobs:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Resolve changed-from baseline
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run source-contract lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
- name: Run lint module tests
|
||||
run: go test -C lint ./... -count=1
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
install-only: true
|
||||
- name: Validate GoReleaser configuration
|
||||
run: goreleaser check
|
||||
- name: Check Extended installer syntax
|
||||
shell: pwsh
|
||||
run: |
|
||||
sh -n scripts/install-extended.sh
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path scripts/install-extended.ps1),
|
||||
[ref]$tokens,
|
||||
[ref]$errors
|
||||
) | Out-Null
|
||||
if ($errors.Count -ne 0) {
|
||||
$errors | ForEach-Object { Write-Error $_ }
|
||||
exit 1
|
||||
}
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
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: Resolve changed-from baseline
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
|
||||
path: .tmp/quality-gate/facts.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . ..
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
@@ -283,25 +100,16 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
||||
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
|
||||
# Extended implementation files are selected by build tags and would
|
||||
# otherwise be absent from the uploaded report. Their race-enabled
|
||||
# suite runs in extended-integration; this pass contributes coverage.
|
||||
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
|
||||
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
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage-standard.txt,coverage-extended.txt
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
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
|
||||
@@ -311,31 +119,21 @@ jobs:
|
||||
- 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
|
||||
report_coverage() {
|
||||
profile="$1"
|
||||
label="$2"
|
||||
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ ! -f "$profile" ]; then
|
||||
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
return
|
||||
fi
|
||||
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
|
||||
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="$profile" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
}
|
||||
report_coverage coverage-standard.txt Standard
|
||||
report_coverage coverage-extended.txt Extended
|
||||
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
|
||||
@@ -386,45 +184,17 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
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: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -432,50 +202,18 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -483,75 +221,25 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
id: build_cli
|
||||
run: make build
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
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
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
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 "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
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
|
||||
@@ -566,9 +254,6 @@ jobs:
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -606,7 +291,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -618,44 +303,27 @@ jobs:
|
||||
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 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.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
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Graduation to required is tracked in
|
||||
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
||||
# consecutive weeks with zero false positives).
|
||||
# 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.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}" \
|
||||
"${{ needs.extended-integration.result }}" \
|
||||
"${{ needs.extended-platform-security.result }}"; do
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
28
.github/workflows/comment-audit.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
204
.github/workflows/release.yml
vendored
204
.github/workflows/release.yml
vendored
@@ -9,45 +9,10 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
goreleaser:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -61,176 +26,35 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Build and upload draft release with GoReleaser
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Verify release edition identities
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
|
||||
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
|
||||
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
|
||||
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
|
||||
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
|
||||
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
|
||||
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Verify release platform asset matrix
|
||||
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Attest release archives
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
|
||||
with:
|
||||
subject-path: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/checksums.txt
|
||||
dist/install-extended.sh
|
||||
dist/install-extended.ps1
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
|
||||
dist/install-extended.sh dist/install-extended.ps1 \
|
||||
npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- name: Publish verified GitHub release
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const { owner, repo } = context.repo;
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const matches = releases.filter((release) => release.tag_name === tag);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
|
||||
}
|
||||
const release = matches[0];
|
||||
if (!release.draft) {
|
||||
throw new Error(`release ${tag} became public before verification completed`);
|
||||
}
|
||||
|
||||
const checksumPath = "dist/checksums.txt";
|
||||
const checksumBody = fs.readFileSync(checksumPath, "utf8");
|
||||
const expectedDigests = new Map();
|
||||
for (const line of checksumBody.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
|
||||
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
|
||||
const name = match[2];
|
||||
if (expectedDigests.has(name)) {
|
||||
throw new Error(`duplicate checksums.txt entry: ${name}`);
|
||||
}
|
||||
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
|
||||
}
|
||||
expectedDigests.set(
|
||||
"checksums.txt",
|
||||
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
|
||||
);
|
||||
|
||||
const actualNames = release.assets.map((asset) => asset.name).sort();
|
||||
const expectedNames = [...expectedDigests.keys()].sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error(
|
||||
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
for (const asset of release.assets) {
|
||||
const expected = expectedDigests.get(asset.name);
|
||||
if (!asset.digest) {
|
||||
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
|
||||
}
|
||||
if (asset.digest.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
draft: false,
|
||||
make_latest: "true",
|
||||
});
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: build-release
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
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 }}
|
||||
run: npm publish --access public
|
||||
|
||||
605
.github/workflows/semantic-review.yml
vendored
605
.github/workflows/semantic-review.yml
vendored
@@ -1,605 +0,0 @@
|
||||
name: Semantic Review
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pr-quality-summary:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request for summary
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify summary facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract summary facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Publish PR quality summary
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/ci-quality-summary-publish.js");
|
||||
await publish({ github, context, core });
|
||||
|
||||
semantic-review:
|
||||
needs: pr-quality-summary
|
||||
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("head_owner", pr.head.repo.owner.login);
|
||||
core.setOutput("head_repo", pr.head.repo.name);
|
||||
core.setOutput("head_repo_id", String(pr.head.repo.id));
|
||||
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Publish pre-checkout semantic review failure
|
||||
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
|
||||
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
|
||||
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR base changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.repo.id !== context.payload.repository.id) {
|
||||
throw new Error("PR base repo mismatch before infrastructure failure check");
|
||||
}
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
|
||||
head_sha: headSha,
|
||||
status: "completed",
|
||||
conclusion: runtimeBlockMode ? "failure" : "neutral",
|
||||
output: {
|
||||
title: "Semantic review infrastructure failure",
|
||||
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
|
||||
},
|
||||
});
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify semantic facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
|
||||
throw new Error("missing verified facts artifact binding");
|
||||
}
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract semantic facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Download PR semantic waiver config
|
||||
id: waiver_config
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
|
||||
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
|
||||
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
|
||||
if (!headOwner || !headRepo) {
|
||||
throw new Error("missing verified semantic review head repository");
|
||||
}
|
||||
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
|
||||
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
|
||||
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
|
||||
if (!headIsBaseRepo) {
|
||||
core.notice("fork PR semantic waiver config is ignored");
|
||||
core.setOutput("path", "");
|
||||
return;
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: waiverPath,
|
||||
ref: headSha,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
|
||||
throw new Error(`${waiverPath} is not a base64 file at PR head`);
|
||||
}
|
||||
if (data.size > 256 * 1024) {
|
||||
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
|
||||
}
|
||||
content = Buffer.from(data.content, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outPath, content);
|
||||
core.setOutput("path", outPath);
|
||||
|
||||
- name: Run semantic review
|
||||
id: semantic
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
|
||||
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
|
||||
ARK_MODEL: ${{ vars.ARK_MODEL }}
|
||||
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
run: |
|
||||
args=(
|
||||
--repo .
|
||||
--facts facts.json
|
||||
--decision-out decision.json
|
||||
--markdown-out semantic-review.md
|
||||
)
|
||||
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
|
||||
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
|
||||
fi
|
||||
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
|
||||
args+=(--block)
|
||||
fi
|
||||
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
|
||||
|
||||
- name: Publish semantic review
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/semantic-review-publish.js");
|
||||
await publish({ github, context, core });
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -27,9 +22,6 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
@@ -43,8 +35,6 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
@@ -54,4 +44,3 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
@@ -29,11 +29,11 @@ linters:
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- depguard # blocks forbidden package imports
|
||||
- forbidigo # forbids specific function calls
|
||||
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
# - errname # checks that error types are named XxxError
|
||||
# - errorlint # checks error wrapping best practices
|
||||
# - gosec # security-oriented linter
|
||||
# - misspell # finds commonly misspelled English words
|
||||
# - staticcheck # comprehensive static analysis
|
||||
@@ -49,47 +49,24 @@ linters:
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
|
||||
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
|
||||
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
|
||||
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
|
||||
# next rule; elsewhere only errs-no-bare-wrap fires.
|
||||
- path-except: (shortcuts/|internal/|cmd/|events/)
|
||||
linters:
|
||||
- forbidigo
|
||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
||||
# the rules below.
|
||||
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
|
||||
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/qualitygate/cmd contains standalone CI tools. Their main
|
||||
# entrypoints legitimately own process exit codes and stdio, matching the
|
||||
# old tools/ layout before these packages moved under internal/.
|
||||
- path: internal/qualitygate/cmd/[^/]+/main\.go$
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced across every command/wire boundary by
|
||||
# structural prefix, so any future business domain or command is covered
|
||||
# without editing an allowlist. Genuine intermediate wraps inside these
|
||||
# paths use //nolint:forbidigo with a reason.
|
||||
- path-except: (cmd/|shortcuts/|events/)
|
||||
text: errs-no-bare-wrap
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -110,12 +87,13 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
# ── legacy output.Err* helpers banned on migrated paths ──
|
||||
# output.ErrBare is intentionally not listed — it is the predicate-
|
||||
# command silent-exit signal, outside the typed envelope contract.
|
||||
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── 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
|
||||
|
||||
@@ -5,8 +5,7 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
- id: standard
|
||||
binary: lark-cli
|
||||
- binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
@@ -18,55 +17,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- id: extended
|
||||
binary: lark-cli
|
||||
tags:
|
||||
- extended
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
archives:
|
||||
- id: standard
|
||||
ids:
|
||||
- standard
|
||||
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
- CHANGELOG.md
|
||||
- id: extended
|
||||
ids:
|
||||
- extended
|
||||
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
format: zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
@@ -74,18 +30,6 @@ archives:
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
extra_files:
|
||||
- glob: ./scripts/install-extended.sh
|
||||
- glob: ./scripts/install-extended.ps1
|
||||
|
||||
release:
|
||||
# Keep assets undiscoverable by releases/latest until the workflow has
|
||||
# independently verified checksums, edition identity, and platform coverage.
|
||||
draft: true
|
||||
replace_existing_draft: true
|
||||
extra_files:
|
||||
- glob: ./scripts/install-extended.sh
|
||||
- glob: ./scripts/install-extended.ps1
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
48
AGENTS.md
48
AGENTS.md
@@ -10,10 +10,9 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
@@ -76,50 +75,12 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -131,7 +92,6 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
791
CHANGELOG.md
791
CHANGELOG.md
@@ -2,764 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
|
||||
- add wiki move-to-drive shortcut (#1869)
|
||||
- **apps**: add role management shortcuts (#1881)
|
||||
- **drive**: add secure label support and clarify comment location API (#1913)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve dashboard shortcut guidance (#1787)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
|
||||
|
||||
### Misc
|
||||
|
||||
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
|
||||
- **drive**: optimize drive +delete workflow (#1909)
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
|
||||
- add minutes permission application shortcut (#1876)
|
||||
- **drive**: support apps in list comments (#1877)
|
||||
- slide style
|
||||
- edit ppt template
|
||||
- **slides**: add sxsd validation to slides lint
|
||||
- **slides**: validate iconpark icon types in slides lint
|
||||
- **slides**: lint before create
|
||||
- **apps**: add automation trigger commands for Miaoda (#1886)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- unify dry-run output contract (#1870)
|
||||
- **skills**: align skill guidance with the typed error contract (#1786)
|
||||
- **slides**: limit slides screenshot page requests
|
||||
- **slides**: detect lark slides text overflow overlap
|
||||
- **vc**: align meeting query scopes by identity (#1850)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify task search relevance filters (#1884)
|
||||
- surface minutes permission application in skill description (#1890)
|
||||
- clarify okr progress children (#1861)
|
||||
- **slides**: prefer slides xml-get shortcut
|
||||
- **calendar**: document setting meeting owner via full API (#1903)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **slides**: streamline create workflow and validate SML namespaces
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: address PR review feedback
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
|
||||
- **slides**: add slides chart demo reference
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- register and consume --json shorthand for custom-format shortcuts (#1737)
|
||||
- **drive**: abort push on parent sibling limit (#1813)
|
||||
|
||||
### Documentation
|
||||
|
||||
- require native charts in slide planning
|
||||
- register knowledge organize workflow (#1828)
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||
- **contact**: Add contact skill domain guidance (#1144)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Use JSON skills list during update (#1251)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||
- **vc-agent**: Require explicit leave request (#1260)
|
||||
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
@@ -1722,39 +964,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
|
||||
75
Makefile
75
Makefile
@@ -5,25 +5,10 @@ BINARY := lark-cli
|
||||
MODULE := github.com/larksuite/cli
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
DATE := $(shell date +%Y-%m-%d)
|
||||
NODE ?= node
|
||||
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
|
||||
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
|
||||
QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -47,61 +32,21 @@ fmt-check:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/release-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
|
||||
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
|
||||
# Deliberate: local `make test` exercises the L4 plugin contract by default.
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
go run ./internal/qualitygate/cmd/manifest-export \
|
||||
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
|
||||
LARKSUITE_CLI_APP_ID=dry-run \
|
||||
LARKSUITE_CLI_APP_SECRET=dry-run \
|
||||
LARKSUITE_CLI_BRAND=feishu \
|
||||
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
go run ./internal/qualitygate/cmd/quality-gate check \
|
||||
--repo . \
|
||||
--cli-bin ./$(BINARY) \
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
@@ -114,20 +59,6 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# extended-test compiles and exercises the separately distributed Extended
|
||||
# edition. The default build remains the Standard npm/npx binary.
|
||||
extended-test:
|
||||
go build -tags extended -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
45
README.md
45
README.md
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ 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) |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -198,7 +198,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 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # Comma-separated values
|
||||
```
|
||||
|
||||
### JSON Output Contract
|
||||
|
||||
With `--format json` (the default), success and error envelopes are distinct.
|
||||
|
||||
Success goes to **stdout**, exit code `0`:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
Errors go to **stderr**, non-zero exit code:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
@@ -285,29 +267,6 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
45
README.zh.md
45
README.zh.md
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # 逗号分隔值
|
||||
```
|
||||
|
||||
### JSON 输出契约
|
||||
|
||||
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||
|
||||
成功信封写入 **stdout**,退出码 0:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
错误信封写入 **stderr**,退出码非 0:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||
|
||||
### 分页
|
||||
|
||||
```bash
|
||||
@@ -286,29 +268,6 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Affordance
|
||||
|
||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
|
||||
## Format
|
||||
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### Skills bullet skill names, or name/relpath references
|
||||
(lark-contact/references/x.md), to read for usage;
|
||||
merged with the domain `> skill:` default (deduped,
|
||||
domain first)
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||
replace the Go tips (not merged), so keep tips in one place.
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
Fetch the full content of a single message by id.
|
||||
|
||||
### Avoid when
|
||||
- Reading several at once → use [[messages batch_get]]
|
||||
|
||||
### Prerequisites
|
||||
- message_id from [[messages list]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Fetch one message**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||
@@ -1,90 +0,0 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## +search-user
|
||||
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-user.md
|
||||
|
||||
### Avoid when
|
||||
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Find a user by name**
|
||||
```bash
|
||||
lark-cli contact +search-user --query "alice" --as user
|
||||
```
|
||||
|
||||
**Fetch known users by open_id (me = yourself)**
|
||||
```bash
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-get-user.md
|
||||
|
||||
### Avoid when
|
||||
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||
|
||||
### Tips
|
||||
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||
- --user-id-type must match the id you pass (default open_id)
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
### Avoid when
|
||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||
|
||||
### Tips
|
||||
- Off by default — set include_personal_status / include_description to true under query_option
|
||||
- ids in user_ids must match --user-id-type (default open_id)
|
||||
|
||||
### Examples
|
||||
|
||||
**Bulk-query status and signature**
|
||||
```bash
|
||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||
```
|
||||
130
cmd/api/api.go
130
cmd/api/api.go
@@ -10,7 +10,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -67,21 +66,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
||||
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
||||
|
||||
Prefer the typed domain command when one exists — it validates parameters,
|
||||
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
||||
guidance that this raw command does not. If a domain command covers your task
|
||||
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
||||
|
||||
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
||||
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
||||
|
||||
Examples:
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
@@ -104,7 +90,6 @@ Examples:
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
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)")
|
||||
@@ -130,13 +115,6 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -144,13 +122,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--params and --data cannot both read from stdin (-)").
|
||||
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
|
||||
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
|
||||
)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
@@ -180,10 +152,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--data must be a JSON object when used with --file").
|
||||
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
|
||||
WithParam("--data")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,13 +195,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output and --page-all are mutually exclusive").
|
||||
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
|
||||
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
|
||||
)
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -250,9 +213,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts)
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -269,7 +232,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -279,7 +242,7 @@ func apiRun(opts *APIOptions) error {
|
||||
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
||||
// through here keep their canonical message / hint from BuildAPIError;
|
||||
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -299,94 +262,55 @@ func apiRun(opts *APIOptions) error {
|
||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||
// *ExitError enrichment; typed errors flow through unchanged.
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
return errs.MarkRaw(apiErr)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -4,48 +4,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -69,70 +47,22 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Dry Run") {
|
||||
t.Error("expected dry run output")
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", got["data"])
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||
if !strings.Contains(output, "/open-apis/test") {
|
||||
t.Error("expected path in dry run output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,25 +77,14 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok || data["result"] != "success" {
|
||||
t.Fatalf("data = %#v, want result=success", got["data"])
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Error("expected 'success' in output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +93,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -182,28 +101,12 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty HTTP method")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method") {
|
||||
t.Fatalf("error should name the method argument, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -216,7 +119,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -282,7 +185,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -301,7 +204,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -320,7 +223,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -337,7 +240,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -352,9 +255,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -365,7 +265,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -374,33 +274,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
savedPath, _ := got["saved_path"].(string)
|
||||
if savedPath == "" {
|
||||
t.Fatalf("saved_path missing from output: %#v", got)
|
||||
}
|
||||
// The file must land inside the temporary cwd — this pins the isolation
|
||||
// contract: rolling back TestChdir would leave download.bin in the repo.
|
||||
wantDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
|
||||
if err != nil {
|
||||
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
|
||||
}
|
||||
if gotDir != wantDir {
|
||||
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
|
||||
}
|
||||
content, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "fake-binary-content" {
|
||||
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +296,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -435,16 +310,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
|
||||
t.Fatalf("unexpected fallback envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "u123") {
|
||||
t.Error("expected user_id in JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,11 +324,11 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
"code": 230001, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -469,20 +336,12 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
t.Fatal("expected an error for non-zero code")
|
||||
}
|
||||
// Should still output the response body so user can see the error details
|
||||
if !strings.Contains(stdout.String(), "230027") {
|
||||
if !strings.Contains(stdout.String(), "230001") {
|
||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||
if !strings.Contains(stdout.String(), "no permission") {
|
||||
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
@@ -502,7 +361,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -518,274 +377,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code on later page")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
|
||||
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "\n \"code\"") {
|
||||
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("data.items = %#v, want one item", data["items"])
|
||||
}
|
||||
}
|
||||
|
||||
type apiContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
data interface{}
|
||||
match string
|
||||
}
|
||||
|
||||
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
|
||||
|
||||
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
p.called = true
|
||||
p.path = req.Path
|
||||
p.data = req.Data
|
||||
if p.match != "" {
|
||||
b, _ := json.Marshal(req.Data)
|
||||
if !strings.Contains(string(b), p.match) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
alert, ok := got["_content_safety_alert"].(map[string]interface{})
|
||||
if !ok || alert["provider"] != "api-test" {
|
||||
t.Fatalf("missing content safety alert in envelope: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan streamed paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id":"1"`) {
|
||||
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
provider := &apiContentSafetyProvider{match: "blocked"}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype || p.Code != code {
|
||||
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
@@ -814,7 +405,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -834,7 +425,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -853,7 +444,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -884,7 +475,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -905,7 +496,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -923,7 +514,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -952,7 +543,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -973,7 +564,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -992,7 +583,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -1010,7 +601,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
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"})
|
||||
@@ -1027,7 +618,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -1044,7 +635,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -1067,30 +658,18 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
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()
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,7 +699,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1139,177 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one api --file upload through the mock
|
||||
// transport and returns a map of field name -> part filename parsed from the
|
||||
// captured multipart body, plus the map of text form fields. It fails the test
|
||||
// if the captured request is not multipart/form-data.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
fields := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
} else {
|
||||
buf := &bytes.Buffer{}
|
||||
_, _ = buf.ReadFrom(part)
|
||||
fields[part.FormName()] = buf.String()
|
||||
}
|
||||
}
|
||||
return filenames, fields
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if _, ok := filenames["upload"]; !ok {
|
||||
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
|
||||
}
|
||||
if got := filenames["upload"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
|
||||
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, fields := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
|
||||
}
|
||||
if got := fields["type"]; got != "attachment" {
|
||||
t.Fatalf("text field type = %q, want %q", got, "attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "unknown-file" {
|
||||
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -31,23 +30,21 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
// 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)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
login := NewCmdAuthLogin(f, nil)
|
||||
logout := NewCmdAuthLogout(f, nil)
|
||||
status := NewCmdAuthStatus(f, nil)
|
||||
scopes := NewCmdAuthScopes(f, nil)
|
||||
list := NewCmdAuthList(f, nil)
|
||||
check := NewCmdAuthCheck(f, nil)
|
||||
qrcode := NewCmdAuthQRCode(f, nil)
|
||||
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
|
||||
cmdutil.SetRuntimeCapabilities(diagnostic)
|
||||
}
|
||||
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
@@ -530,7 +416,10 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"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 {
|
||||
@@ -555,19 +444,3 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
|
||||
f := newFactoryWithExternalProvider(t)
|
||||
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := NewCmdAuth(f)
|
||||
matched, _, err := cmd.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
|
||||
t.Fatalf("read-only command blocked: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -21,7 +19,6 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -35,12 +32,11 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRunContext(cmd.Context(), opts)
|
||||
return authCheckRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
@@ -48,10 +44,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
func authCheckRun(opts *CheckOptions) error {
|
||||
return authCheckRunContext(context.Background(), opts)
|
||||
}
|
||||
|
||||
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
@@ -63,74 +55,18 @@ func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
|
||||
}
|
||||
|
||||
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
|
||||
TokenSpec: credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: config.AppID,
|
||||
},
|
||||
IncludeScopes: true,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to inspect user authorization: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
if inspection == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"credential source returned no authorization inspection")
|
||||
}
|
||||
|
||||
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
|
||||
if config.UserOpenId == "" {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
if !inspection.Present {
|
||||
if inspection.Source.Managed {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"credential source %q did not provide a user access token", inspection.Source.Name).
|
||||
WithHint("authorize the user through the selected credential source")
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
|
||||
switch inspection.ScopeState {
|
||||
case credential.ScopeUnsupported:
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
|
||||
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
|
||||
case credential.ScopeUnknown:
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
|
||||
WithHint("configure the credential source to return trusted scopes for user access tokens")
|
||||
case credential.ScopeKnown:
|
||||
// Continue below.
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
|
||||
}
|
||||
|
||||
suggestion := ""
|
||||
missing := larkauth.MissingScopes(inspection.Scopes, required)
|
||||
if inspection.Source.Managed {
|
||||
if len(missing) > 0 {
|
||||
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
|
||||
}
|
||||
} else {
|
||||
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
|
||||
}
|
||||
|
||||
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
|
||||
missing := larkauth.MissingScopes(availableScopes, required)
|
||||
missing := larkauth.MissingScopes(stored.Scope, required)
|
||||
missingSet := make(map[string]bool, len(missing))
|
||||
for _, s := range missing {
|
||||
missingSet[s] = true
|
||||
@@ -144,8 +80,8 @@ func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 && suggestion != "" {
|
||||
result["suggestion"] = suggestion
|
||||
if len(missing) > 0 {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !ok {
|
||||
|
||||
@@ -4,20 +4,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -39,9 +33,12 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
||||
}
|
||||
var bare *output.BareError
|
||||
var bare *output.ExitError
|
||||
if !errors.As(err, &bare) {
|
||||
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
if bare.Detail != nil {
|
||||
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
@@ -152,128 +149,6 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type authCheckExternalProvider struct {
|
||||
token *extcred.Token
|
||||
capabilities credential.ProviderCapabilities
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
|
||||
|
||||
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
p.resolveCalls++
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
|
||||
return p.capabilities
|
||||
}
|
||||
|
||||
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
provider := &authCheckExternalProvider{
|
||||
token: token,
|
||||
capabilities: credential.ProviderCapabilities{
|
||||
ProvidesOnDemandAuth: true,
|
||||
CanInspectScopes: canInspectScopes,
|
||||
},
|
||||
}
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
return f, provider
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
|
||||
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
|
||||
Value: "external-uat",
|
||||
Scopes: "im:message docx:document",
|
||||
})
|
||||
stdout := f.IOStreams.Out.(*bytes.Buffer)
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authCheckRun() error = %v", err)
|
||||
}
|
||||
if provider.resolveCalls != 1 {
|
||||
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
|
||||
}
|
||||
granted, ok := payload["granted"].([]any)
|
||||
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
||||
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
|
||||
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
|
||||
Value: "proxy-placeholder",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
|
||||
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
|
||||
}
|
||||
if provider.resolveCalls != 0 {
|
||||
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
|
||||
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
|
||||
Value: "external-uat",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
|
||||
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
// Scope validation is a real input error, not a predicate negative
|
||||
// answer — it must surface as a typed ValidationError with the normal
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -36,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -47,20 +44,12 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
@@ -72,14 +61,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,11 +296,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -318,9 +317,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -407,11 +404,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -92,11 +92,16 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
dm := domainMeta{Name: name}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
@@ -878,7 +871,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
// contract that when --json is set and pollDeviceToken returns OK=false,
|
||||
// stdout carries the structured authorization_failed event and stderr is
|
||||
// NOT polluted with a typed envelope. The returned error is a bare
|
||||
// BareError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// ExitError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// without emitting a second envelope on top of the JSON event.
|
||||
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
@@ -945,13 +938,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
||||
}
|
||||
|
||||
// Returned error must be the bare *output.BareError signal (no envelope).
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
||||
// Returned error must be the bare *output.ExitError signal (no envelope).
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if bareErr.Code != output.ExitAuth {
|
||||
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail != nil {
|
||||
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -44,10 +42,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
|
||||
func authStatusRun(opts *StatusOptions) error {
|
||||
f := opts.Factory
|
||||
editionStatus, err := inspectEditionStatus(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
@@ -68,9 +62,7 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
if !applyEditionStatus(result, diagnostics, editionStatus) {
|
||||
addStatusNote(result, diagnostics)
|
||||
}
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
type editionStatusState struct {
|
||||
provider string
|
||||
variant string
|
||||
}
|
||||
|
||||
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(context.Background())
|
||||
if err != nil {
|
||||
return editionStatusState{}, err
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
state := editionStatusState{provider: source.Name}
|
||||
description := f.RuntimeDescription()
|
||||
if description.Managed {
|
||||
state.variant = description.Variant
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
|
||||
if state.provider == "" {
|
||||
return false
|
||||
}
|
||||
result["source"] = "external"
|
||||
result["credentialProvider"] = state.provider
|
||||
if state.variant != "" {
|
||||
result["externalCredentialMode"] = state.variant
|
||||
}
|
||||
switch {
|
||||
case !diagnostics.User.Available && diagnostics.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
|
||||
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
|
||||
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
|
||||
case !diagnostics.User.Available && !diagnostics.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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/runtimeplan"
|
||||
)
|
||||
|
||||
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubExternalProvider{name: "env"}},
|
||||
nil, nil, f.HttpClient,
|
||||
)
|
||||
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
|
||||
Description: runtimeplan.Description{
|
||||
Managed: true,
|
||||
Variant: "managed-test",
|
||||
},
|
||||
}))
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["source"] != "external" ||
|
||||
got["credentialProvider"] != "env" ||
|
||||
got["externalCredentialMode"] != "managed-test" ||
|
||||
got["identity"] != "bot" {
|
||||
t.Fatalf("output = %#v", got)
|
||||
}
|
||||
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
|
||||
!strings.Contains(note, "external credential provider env") {
|
||||
t.Fatalf("note = %q", note)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
type editionStatusState struct{}
|
||||
|
||||
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
|
||||
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubExternalProvider{name: "env"}},
|
||||
nil, nil, f.HttpClient,
|
||||
)
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
|
||||
if _, exists := got[field]; exists {
|
||||
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
var note string
|
||||
if err := json.Unmarshal(got["note"], ¬e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(note, "lark-cli auth login") {
|
||||
t.Fatalf("Standard note = %q, want established login guidance", note)
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,6 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("json.Unmarshal(raw) error = %v", err)
|
||||
}
|
||||
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
|
||||
if _, exists := raw[field]; exists {
|
||||
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
150
cmd/build.go
150
cmd/build.go
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -17,19 +16,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/runtimebootstrap"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -38,34 +31,9 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
runtime *runtimebootstrap.Result
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
// any command registration touches the runtime catalog. Without it the
|
||||
// registry's sync.Once locks onto the Feishu default at first catalog access,
|
||||
// long before the lazily-resolved config brand is known — see
|
||||
// ResolveStartupBrand for the caller-side resolution.
|
||||
func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
}
|
||||
}
|
||||
|
||||
// withRuntimeBootstrap shares one invocation snapshot across registry,
|
||||
// credentials, transports, and command capabilities.
|
||||
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.runtime = runtime
|
||||
}
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -83,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// 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
|
||||
@@ -105,41 +61,6 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutPlugins builds only repository-owned commands. It is intended for
|
||||
// inspection tools that need a deterministic command tree.
|
||||
func WithoutPlugins() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipPlugins = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutServiceCommands builds only hand-authored commands. It is intended for
|
||||
// repository quality gates that should not depend on the remote OpenAPI
|
||||
// metadata command surface.
|
||||
func WithoutServiceCommands() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipService = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceCatalog builds generated service commands from a specific metadata
|
||||
// catalog. It is intended for offline inspection tools that need deterministic
|
||||
// embedded metadata while production execution keeps using the runtime catalog.
|
||||
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.serviceCatalog = &catalog
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree. It also installs registered
|
||||
// plugins and emits the Startup lifecycle event during assembly --
|
||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||
@@ -153,9 +74,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal assembles the command tree from one immutable startup
|
||||
// configuration snapshot. Profile selection happens before any registry
|
||||
// network decision and the same result is passed to the Factory.
|
||||
// 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.
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
@@ -178,33 +99,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
startup := cfg.runtime
|
||||
if startup == nil {
|
||||
startup = runtimebootstrap.Resolve(inv.Profile)
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
// Runtime policy can close direct metadata egress before any command is
|
||||
// registered, without exposing a concrete credential mode here.
|
||||
registryBrand := cfg.startupBrand
|
||||
if registryBrand == "" {
|
||||
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
|
||||
}
|
||||
if !startup.Plan.AllowsRemoteMetadata() {
|
||||
if registryBrand == "" {
|
||||
registryBrand = core.BrandFeishu
|
||||
}
|
||||
registry.InitEmbeddedWithBrand(registryBrand)
|
||||
} else if registryBrand != "" {
|
||||
registry.InitWithBrand(registryBrand)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -217,19 +115,8 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
// Root-only usage template (curated Usage synopsis + skills footer); see
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
@@ -241,39 +128,20 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
registerEditionCommands(rootCmd, f)
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
if !cfg.skipService {
|
||||
if cfg.serviceCatalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||
} else {
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
}
|
||||
}
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
|
||||
if root == nil {
|
||||
t.Fatal("Build returned nil root")
|
||||
}
|
||||
if findCommand(root, "api") == nil {
|
||||
t.Fatal("builtin api command missing")
|
||||
}
|
||||
if findCommand(root, "docs +fetch") == nil {
|
||||
t.Fatal("builtin docs +fetch shortcut missing")
|
||||
}
|
||||
}
|
||||
|
||||
func findCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(path)
|
||||
cmd := root
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cmd = next
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
|
||||
previousWorkspace := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workspace core.Workspace
|
||||
signalName string
|
||||
signalValue string
|
||||
expectedAppID string
|
||||
expectedBrand core.LarkBrand
|
||||
}{
|
||||
{
|
||||
name: "local",
|
||||
workspace: core.WorkspaceLocal,
|
||||
expectedAppID: "cli_local",
|
||||
expectedBrand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
name: "openclaw",
|
||||
workspace: core.WorkspaceOpenClaw,
|
||||
signalName: "OPENCLAW_CLI",
|
||||
signalValue: "1",
|
||||
expectedAppID: "cli_openclaw",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
{
|
||||
name: "hermes",
|
||||
workspace: core.WorkspaceHermes,
|
||||
signalName: "HERMES_HOME",
|
||||
signalValue: "/managed/hermes",
|
||||
expectedAppID: "cli_hermes",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
{
|
||||
name: "lark_channel",
|
||||
workspace: core.WorkspaceLarkChannel,
|
||||
signalName: "LARK_CHANNEL",
|
||||
signalValue: "1",
|
||||
expectedAppID: "cli_lark_channel",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearWorkspaceSignals(t)
|
||||
clearCredentialSignals(t)
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
|
||||
t.Setenv(envvars.CliExternalCredentialConfig,
|
||||
filepath.Join(configRoot, "missing-external-credential.json"))
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
if tt.signalName != "" {
|
||||
t.Setenv(tt.signalName, tt.signalValue)
|
||||
}
|
||||
|
||||
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
|
||||
if !tt.workspace.IsLocal() {
|
||||
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
|
||||
}
|
||||
|
||||
// Execute resolves the registry brand before entering
|
||||
// buildInternal. Pin that ordering independently.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if got := selectInvocationWorkspace(); got != tt.workspace {
|
||||
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
|
||||
}
|
||||
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
|
||||
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
|
||||
}
|
||||
|
||||
// Build/buildInternal is also a public construction path. Reset the
|
||||
// process state to local so the test proves it establishes the
|
||||
// workspace before SelectProfile captures the immutable snapshot.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
factory, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), io.Discard, io.Discard),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
)
|
||||
if got := core.CurrentWorkspace(); got != tt.workspace {
|
||||
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
|
||||
}
|
||||
config, err := factory.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.Config() error = %v", err)
|
||||
}
|
||||
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
|
||||
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
|
||||
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearWorkspaceSignals(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"OPENCLAW_CLI",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_SERVICE_MARKER",
|
||||
"OPENCLAW_SERVICE_VERSION",
|
||||
"OPENCLAW_GATEWAY_PORT",
|
||||
"OPENCLAW_SHELL",
|
||||
"HERMES_HOME",
|
||||
"HERMES_QUIET",
|
||||
"HERMES_EXEC_ASK",
|
||||
"HERMES_GATEWAY_TOKEN",
|
||||
"HERMES_SESSION_KEY",
|
||||
"LARK_CHANNEL",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func clearCredentialSignals(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
envvars.CliAppID,
|
||||
envvars.CliAppSecret,
|
||||
envvars.CliBrand,
|
||||
envvars.CliUserAccessToken,
|
||||
envvars.CliTenantAccessToken,
|
||||
envvars.CliDefaultAs,
|
||||
envvars.CliStrictMode,
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func writeWorkspaceProfile(
|
||||
t *testing.T,
|
||||
workspace core.Workspace,
|
||||
name string,
|
||||
appID string,
|
||||
brand core.LarkBrand,
|
||||
) {
|
||||
t.Helper()
|
||||
previous := core.CurrentWorkspace()
|
||||
core.SetCurrentWorkspace(workspace)
|
||||
defer core.SetCurrentWorkspace(previous)
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: name,
|
||||
Apps: []core.AppConfig{{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: core.PlainSecret("test-secret-" + name),
|
||||
Brand: brand,
|
||||
Users: []core.AppUser{},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// universalFlags are accepted by every command (cobra auto-injects help; the
|
||||
// root injects version). They are never reported as unknown.
|
||||
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
||||
|
||||
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
||||
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
||||
// "contact +search-user". The root command is the empty path "".
|
||||
type catalog struct {
|
||||
flagsByPath map[string]map[string]bool
|
||||
group map[string]bool // paths that are parent groups (have subcommands)
|
||||
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
||||
}
|
||||
|
||||
func newCatalog() *catalog {
|
||||
return &catalog{
|
||||
flagsByPath: map[string]map[string]bool{},
|
||||
group: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// setGroup records whether path is a parent group (has subcommands). Leftover
|
||||
// words after a group node are unknown subcommands; after a leaf they are
|
||||
// positionals (e.g. "api GET /path").
|
||||
func (c *catalog) setGroup(path string, isGroup bool) {
|
||||
if isGroup {
|
||||
c.group[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
||||
|
||||
// addCommand registers a command path and the flags it accepts. Repeated calls
|
||||
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
||||
func (c *catalog) addCommand(path string, flags []string) {
|
||||
set := c.flagsByPath[path]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
c.flagsByPath[path] = set
|
||||
}
|
||||
for _, f := range flags {
|
||||
set[f] = true
|
||||
}
|
||||
c.sorted = nil // invalidate cached suggestion list
|
||||
}
|
||||
|
||||
func (c *catalog) hasCommand(path string) bool {
|
||||
_, ok := c.flagsByPath[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
// hasFlag reports whether flag is accepted by command path (universal flags
|
||||
// always pass).
|
||||
func (c *catalog) hasFlag(path, flag string) bool {
|
||||
if universalFlags[flag] {
|
||||
return true
|
||||
}
|
||||
set := c.flagsByPath[path]
|
||||
return set[flag]
|
||||
}
|
||||
|
||||
// longestPrefix returns the longest known command path that is a prefix of
|
||||
// words, plus how many words it consumed. This separates real subcommands from
|
||||
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
||||
// empty it falls back to the root command. ok=false means not even the first
|
||||
// word names a command.
|
||||
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
||||
if len(words) == 0 {
|
||||
if c.hasCommand("") {
|
||||
return "", 0, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
for i := len(words); i >= 1; i-- {
|
||||
cand := strings.Join(words[:i], " ")
|
||||
if c.hasCommand(cand) {
|
||||
return cand, i, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// paths returns all known command paths, sorted.
|
||||
func (c *catalog) paths() []string {
|
||||
out := make([]string, 0, len(c.flagsByPath))
|
||||
for p := range c.flagsByPath {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestCommand returns the known command path closest to want (small edit
|
||||
// distance), for error hints. Returns "" when nothing is reasonably close.
|
||||
func (c *catalog) suggestCommand(want string) string {
|
||||
if c.sorted == nil {
|
||||
c.sorted = c.paths() // built once after the catalog is fully populated
|
||||
}
|
||||
return closest(want, c.sorted)
|
||||
}
|
||||
|
||||
// suggestFlag returns the flag of path closest to flag, for error hints.
|
||||
func (c *catalog) suggestFlag(path, flag string) string {
|
||||
set := c.flagsByPath[path]
|
||||
cands := make([]string, 0, len(set))
|
||||
for f := range set {
|
||||
cands = append(cands, f)
|
||||
}
|
||||
sort.Strings(cands)
|
||||
return closest(flag, cands)
|
||||
}
|
||||
|
||||
// closest returns the candidate with the smallest Levenshtein distance to want,
|
||||
// but only if that distance is within a tolerance scaled to want's length
|
||||
// (avoids absurd suggestions).
|
||||
func closest(want string, cands []string) string {
|
||||
best := ""
|
||||
bestD := 1 << 30
|
||||
for _, cand := range cands {
|
||||
d := levenshtein(want, cand)
|
||||
if d < bestD {
|
||||
bestD, best = d, cand
|
||||
}
|
||||
}
|
||||
tol := len(want)/2 + 1
|
||||
if bestD > tol {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
cur := make([]int, len(rb)+1)
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import "strings"
|
||||
|
||||
// Finding kinds.
|
||||
const (
|
||||
unknownCommand = "unknown_command"
|
||||
unknownFlag = "unknown_flag"
|
||||
)
|
||||
|
||||
// finding is a single mismatch between an example command reference and the
|
||||
// catalog.
|
||||
type finding struct {
|
||||
line int
|
||||
raw string
|
||||
kind string // unknownCommand | unknownFlag
|
||||
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
|
||||
flag string // offending flag (unknownFlag only)
|
||||
suggest string // nearest known command/flag, "" if none close
|
||||
}
|
||||
|
||||
// checkRefs validates refs against cat and returns all mismatches in order.
|
||||
func checkRefs(cat *catalog, refs []ref) []finding {
|
||||
var out []finding
|
||||
for _, r := range refs {
|
||||
path, n, ok := cat.longestPrefix(r.words)
|
||||
if !ok {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Leftover words after a group node are an unknown subcommand (e.g. a
|
||||
// mistyped method like "batch_modify_message"). After a leaf they are
|
||||
// positionals (e.g. "api GET /path"), so only groups trigger this.
|
||||
if n < len(r.words) && cat.isGroup(path) {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
for _, f := range r.flags {
|
||||
if cat.hasFlag(path, f) {
|
||||
continue
|
||||
}
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownFlag,
|
||||
path: path, flag: f, suggest: cat.suggestFlag(path, f),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ref is one lark-cli command reference extracted from a shortcut example.
|
||||
type ref struct {
|
||||
line int // 1-based line number (the line where the command starts)
|
||||
raw string // reconstructed command text, for error display
|
||||
words []string // command words before the first flag (subcommand candidates)
|
||||
flags []string // flag tokens used, e.g. "--query", "-q"
|
||||
}
|
||||
|
||||
const cliToken = "lark-cli"
|
||||
|
||||
// subcommandStart guards against false positives from prose: a real command's
|
||||
// first word is ASCII (a service name or a +shortcut). A token starting with
|
||||
// CJK / punctuation is treated as narration, not a command.
|
||||
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
|
||||
|
||||
// shellStops are standalone tokens that terminate a command (pipes, redirects,
|
||||
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
|
||||
var shellStops = map[string]bool{
|
||||
"|": true, "||": true, "&&": true, "&": true, ";": true,
|
||||
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
|
||||
}
|
||||
|
||||
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
|
||||
// in prose ("auth login." / "auth login,"); stripped so the word still resolves
|
||||
// instead of being dropped as an unknown command or non-ASCII narration.
|
||||
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
|
||||
|
||||
// parseRefs extracts every lark-cli command reference from text (a shortcut's
|
||||
// Tips line, which may embed an "Example: lark-cli ..." command). It is
|
||||
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
|
||||
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
|
||||
// line-continuations are joined first so a multi-line invocation is parsed as
|
||||
// one command; inline-code backticks and trailing # comments terminate it.
|
||||
func parseRefs(content string) []ref {
|
||||
var refs []ref
|
||||
lines := strings.Split(content, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
lineNo := i + 1
|
||||
logical := lines[i]
|
||||
// Shell line continuation: a trailing backslash joins the next physical
|
||||
// line. Without this, flags on the continuation lines of a multi-line
|
||||
// `lark-cli ... \` example are never seen by the checker.
|
||||
for endsWithBackslash(logical) && i+1 < len(lines) {
|
||||
logical = strings.TrimRight(logical, " \t")
|
||||
logical = logical[:len(logical)-1] // drop the trailing backslash
|
||||
i++
|
||||
logical += " " + lines[i]
|
||||
}
|
||||
refs = append(refs, parseLine(logical, lineNo)...)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func endsWithBackslash(s string) bool {
|
||||
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
|
||||
}
|
||||
|
||||
func parseLine(line string, lineNo int) []ref {
|
||||
var refs []ref
|
||||
rest := line
|
||||
for {
|
||||
idx := strings.Index(rest, cliToken)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := rest[idx+len(cliToken):]
|
||||
beforeOK := idx == 0 || isBoundary(rest[idx-1])
|
||||
afterOK := after == "" || isBoundary(after[0])
|
||||
if beforeOK && afterOK {
|
||||
if words, flags, raw, ok := parseCmd(after); ok {
|
||||
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
|
||||
}
|
||||
}
|
||||
rest = after
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// parseCmd tokenizes the text following "lark-cli" into leading command words
|
||||
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
|
||||
// shell separator (standalone or glued), an inline-code backtick, a comment, or
|
||||
// a placeholder/prose word. ok=false filters out non-commands.
|
||||
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
|
||||
// An inline code span ends at the next backtick; a command never spans one.
|
||||
if i := strings.IndexByte(after, '`'); i >= 0 {
|
||||
after = after[:i]
|
||||
}
|
||||
// Drop $(...) command substitutions so flags belonging to the inner command
|
||||
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
|
||||
after = stripCmdSubst(after)
|
||||
|
||||
var kept []string
|
||||
inFlags := false
|
||||
for _, orig := range strings.Fields(after) {
|
||||
tok := orig
|
||||
if shellStops[tok] || strings.HasPrefix(tok, "#") {
|
||||
break
|
||||
}
|
||||
// A shell separator glued to a token ends the command mid-token
|
||||
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
|
||||
stop := false
|
||||
if i := strings.IndexAny(tok, ";|"); i >= 0 {
|
||||
tok, stop = tok[:i], true
|
||||
}
|
||||
switch {
|
||||
case tok == "" || tok == "-":
|
||||
// empty (after a glued separator) or a bare stdin marker — skip
|
||||
case strings.HasPrefix(tok, "-"):
|
||||
if f := normalizeFlag(tok); f != "" {
|
||||
inFlags = true
|
||||
flags = append(flags, f)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
case inFlags:
|
||||
// positional / flag value after the first flag — not a command word
|
||||
kept = append(kept, tok)
|
||||
default:
|
||||
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
|
||||
// +<verb>, ...) end the command — checked on the RAW token so the
|
||||
// trailing-punct stripping below cannot erase a "..." ellipsis
|
||||
// ("base +..." must stay a placeholder, not become "+").
|
||||
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
// Strip trailing sentence/CJK punctuation so "login." / "login,"
|
||||
// resolve to "login"; non-ASCII narration ends the command.
|
||||
w := strings.TrimRight(tok, wordTrailPunct)
|
||||
if w == "" || hasNonASCII(w) {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
words = append(words, w)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
raw = " " + strings.Join(kept, " ")
|
||||
}
|
||||
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
|
||||
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
|
||||
if len(words) == 0 {
|
||||
return words, flags, raw, len(flags) > 0
|
||||
}
|
||||
if !subcommandStart.MatchString(words[0]) {
|
||||
return nil, nil, "", false
|
||||
}
|
||||
return words, flags, raw, true
|
||||
}
|
||||
|
||||
// stripCmdSubst removes $(...) command substitutions (including nested ones)
|
||||
// from s, leaving the surrounding text intact. Backtick substitutions are
|
||||
// already handled upstream (a command never spans a backtick).
|
||||
func stripCmdSubst(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||
depth = 1
|
||||
i++ // skip '('
|
||||
continue
|
||||
}
|
||||
if depth > 0 {
|
||||
switch s[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isPlaceholderOrProse reports whether a command word is a doc placeholder
|
||||
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
|
||||
// non-ASCII), rather than a literal command token.
|
||||
func isPlaceholderOrProse(w string) bool {
|
||||
if hasNonASCII(w) {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
|
||||
}
|
||||
|
||||
func hasNonASCII(s string) bool {
|
||||
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
|
||||
}
|
||||
|
||||
// flagShape matches the leading flag token, stripping any trailing junk such as
|
||||
// a "=value" suffix or punctuation that bled in from the surrounding markdown
|
||||
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
|
||||
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
|
||||
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
|
||||
|
||||
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
|
||||
// a real flag (e.g. a shell-string fragment like "-草稿'").
|
||||
func normalizeFlag(tok string) string {
|
||||
return flagShape.FindString(tok)
|
||||
}
|
||||
|
||||
func isBoundary(b byte) bool {
|
||||
switch b {
|
||||
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file and its cmdexample_*_test.go siblings implement a test-only check:
|
||||
// the example commands embedded in shortcut definitions (the "Example: lark-cli
|
||||
// ..." lines in each shortcut's Tips, shown in --help) must match the real
|
||||
// command tree. It lives entirely in _test.go files (package cmd_test) so it
|
||||
// ships in no binary and is not importable by product code; the truth source is
|
||||
// cmd.Build, the same tree the binary uses, so the check cannot drift.
|
||||
//
|
||||
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
|
||||
// example using a renamed command or an unaccepted flag — fails that job.
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestShortcutExampleCommands checks the example commands embedded in every
|
||||
// shortcut's Tips against the live command tree. A shortcut that defines no
|
||||
// example is simply skipped.
|
||||
//
|
||||
// Because the examples and the command definitions live in the same Go code,
|
||||
// this is a self-consistency check: any mismatch (an example using a renamed
|
||||
// command or a flag the command doesn't accept) is a bug to fix at the source.
|
||||
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
|
||||
// always a defect, never acceptable "pre-existing drift".
|
||||
func TestShortcutExampleCommands(t *testing.T) {
|
||||
// Reproducibility: use the embedded API metadata (not a developer's stale
|
||||
// ~/.lark-cli remote cache, which can miss commands) and an empty config
|
||||
// dir so local strict mode / plugins / policy cannot reshape the tree.
|
||||
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cat := buildCmdExampleCatalog()
|
||||
|
||||
type located struct {
|
||||
shortcut string
|
||||
f finding
|
||||
}
|
||||
var findings []located
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
var refs []ref
|
||||
for _, tip := range sc.Tips {
|
||||
refs = append(refs, parseRefs(tip)...)
|
||||
}
|
||||
label := strings.TrimSpace(sc.Service + " " + sc.Command)
|
||||
for _, f := range checkRefs(cat, refs) {
|
||||
findings = append(findings, located{shortcut: label, f: f})
|
||||
}
|
||||
}
|
||||
|
||||
if len(findings) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
|
||||
for _, lf := range findings {
|
||||
hint := ""
|
||||
if lf.f.suggest != "" {
|
||||
hint = " (did you mean " + lf.f.suggest + "?)"
|
||||
}
|
||||
if lf.f.kind == unknownFlag {
|
||||
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
|
||||
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
} else {
|
||||
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
|
||||
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
}
|
||||
}
|
||||
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
|
||||
"fix the Example in the shortcut definition.", len(findings))
|
||||
}
|
||||
|
||||
// buildCmdExampleCatalog walks the live cobra command tree and records every
|
||||
// command path (minus the "lark-cli" root prefix) with its accepted flags and
|
||||
// whether it is a parent group. This is the same Build() the binary uses, so
|
||||
// the catalog can never drift from the real commands.
|
||||
func buildCmdExampleCatalog() *catalog {
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
cat := newCatalog()
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
var flags []string
|
||||
add := func(fl *pflag.Flag) {
|
||||
flags = append(flags, "--"+fl.Name)
|
||||
if fl.Shorthand != "" {
|
||||
flags = append(flags, "-"+fl.Shorthand)
|
||||
}
|
||||
}
|
||||
c.Flags().VisitAll(add)
|
||||
c.InheritedFlags().VisitAll(add)
|
||||
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
|
||||
cat.addCommand(path, flags)
|
||||
cat.setGroup(path, c.HasSubCommands())
|
||||
for _, sub := range c.Commands() {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return cat
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testCatalog() *catalog {
|
||||
c := newCatalog()
|
||||
c.addCommand("", []string{"--profile"}) // root
|
||||
c.setGroup("", true)
|
||||
c.addCommand("contact", []string{"--profile"})
|
||||
c.setGroup("contact", true)
|
||||
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
|
||||
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
|
||||
c.addCommand("mail", nil)
|
||||
c.setGroup("mail", true)
|
||||
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
|
||||
c.setGroup("mail user_mailbox.messages", true)
|
||||
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if !c.hasCommand("contact +search-user") {
|
||||
t.Fatal("expected contact +search-user to exist")
|
||||
}
|
||||
if c.hasCommand("contact +nope") {
|
||||
t.Fatal("did not expect contact +nope")
|
||||
}
|
||||
if !c.hasFlag("contact +search-user", "--query") {
|
||||
t.Fatal("--query should be valid")
|
||||
}
|
||||
if c.hasFlag("contact +search-user", "--nope") {
|
||||
t.Fatal("--nope should be invalid")
|
||||
}
|
||||
// universal flags pass on any command
|
||||
for _, f := range []string{"--help", "-h", "--version"} {
|
||||
if !c.hasFlag("contact +search-user", f) {
|
||||
t.Fatalf("universal flag %s should pass", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleLongestPrefix(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
words []string
|
||||
want string
|
||||
wantN int
|
||||
wantOK bool
|
||||
}{
|
||||
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
|
||||
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
|
||||
{[]string{"nope"}, "", 0, false},
|
||||
{nil, "", 0, true}, // empty -> root
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, n, ok := c.longestPrefix(tt.words)
|
||||
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
|
||||
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
|
||||
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refWordsOf(refs []ref) [][]string {
|
||||
var out [][]string
|
||||
for _, r := range refs {
|
||||
out = append(out, r.words)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
|
||||
"```bash",
|
||||
"lark-cli api GET /open-apis/x --params '{}'", // bash block
|
||||
"```",
|
||||
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
|
||||
"npx foo | lark-cli api GET /y", // after a pipe
|
||||
}, "\n")
|
||||
refs := parseRefs(content)
|
||||
if len(refs) != 4 {
|
||||
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
|
||||
}
|
||||
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
|
||||
len(got.flags) != 1 || got.flags[0] != "--query" {
|
||||
t.Errorf("ref0 = %+v", got)
|
||||
}
|
||||
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
|
||||
t.Errorf("ref1 words = %v", got.words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
|
||||
// A line whose first word is prose yields no command at all.
|
||||
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
|
||||
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
|
||||
}
|
||||
// Syntax templates / trailing prose may leave a real leading word ("mail"),
|
||||
// but no placeholder or CJK token may leak into the command words — that is
|
||||
// what prevents false positives like an "<resource>" unknown-command report.
|
||||
for _, line := range []string{
|
||||
"lark-cli mail <resource> <method> [flags]",
|
||||
"lark-cli apps +<verb> [flags]",
|
||||
"lark-cli base +...",
|
||||
"lark-cli mail 写信场景下的格式说明",
|
||||
} {
|
||||
for _, r := range parseRefs(line) {
|
||||
for _, w := range r.words {
|
||||
if isPlaceholderOrProse(w) {
|
||||
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
|
||||
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
|
||||
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
|
||||
t.Errorf("expected flag --help, got %v", refs[0].flags)
|
||||
}
|
||||
// bare "-" (stdin marker) and "=value" suffix
|
||||
refs = parseRefs("lark-cli api GET /x --params={} --data -")
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
flags := strings.Join(refs[0].flags, " ")
|
||||
if flags != "--params --data" {
|
||||
t.Errorf("expected '--params --data', got %q", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheck(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
name string
|
||||
r ref
|
||||
wantKind string // "" = no finding
|
||||
wantPath string
|
||||
}{
|
||||
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
|
||||
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
|
||||
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
|
||||
{"group leftover = unknown subcommand",
|
||||
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
|
||||
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
|
||||
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
|
||||
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fs := checkRefs(c, []ref{tt.r})
|
||||
if tt.wantKind == "" {
|
||||
if len(fs) != 0 {
|
||||
t.Fatalf("expected no finding, got %+v", fs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(fs) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
|
||||
}
|
||||
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
|
||||
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
|
||||
c := testCatalog()
|
||||
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
|
||||
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
|
||||
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
|
||||
// review: backslash continuation, underscore flags, $(...) substitution, glued
|
||||
// separators, trailing punctuation, and the "..." placeholder.
|
||||
func TestCmdExampleParseRefsRobustness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, content, wantWords, wantFlags string
|
||||
wantRefs int
|
||||
}{
|
||||
{"backslash continuation joins flags",
|
||||
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
|
||||
"contact +search-user", "--query --as", 1},
|
||||
{"underscore flag not truncated",
|
||||
"lark-cli whiteboard +update --input_format mermaid",
|
||||
"whiteboard +update", "--input_format", 1},
|
||||
{"command-substitution flags ignored",
|
||||
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
|
||||
"slides x create", "--data --as", 1},
|
||||
{"glued separator truncates",
|
||||
"lark-cli auth login; echo done",
|
||||
"auth login", "", 1},
|
||||
{"trailing CJK punctuation stripped",
|
||||
"用 lark-cli auth login。",
|
||||
"auth login", "", 1},
|
||||
{"ellipsis placeholder stays placeholder",
|
||||
"lark-cli base +...",
|
||||
"base", "", 1},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
refs := parseRefs(tt.content)
|
||||
if len(refs) != tt.wantRefs {
|
||||
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
|
||||
}
|
||||
if tt.wantRefs == 0 {
|
||||
return
|
||||
}
|
||||
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
|
||||
t.Errorf("words=%q want %q", got, tt.wantWords)
|
||||
}
|
||||
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
|
||||
t.Errorf("flags=%q want %q", got, tt.wantFlags)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||
// service command tree for any depth — flat dotted resources AND genuinely
|
||||
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||
// assuming a fixed root->service->resource->method shape.
|
||||
func TestCommandCatalogPath(t *testing.T) {
|
||||
chain := func(names ...string) *cobra.Command {
|
||||
var parent, leaf *cobra.Command
|
||||
for _, n := range names {
|
||||
c := &cobra.Command{Use: n}
|
||||
if parent != nil {
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
parent = c
|
||||
leaf = c
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
leaf *cobra.Command
|
||||
want []string
|
||||
}{
|
||||
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The root command (no parent) has no catalog path.
|
||||
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||
t.Errorf("root path = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -31,9 +32,7 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
case "powershell":
|
||||
return root.GenPowerShellCompletionWithDesc(out)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported shell: %s", args[0]).
|
||||
WithHint("supported shells: bash, zsh, fish, powershell")
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
return "", langSelectionError(err)
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
|
||||
@@ -20,29 +20,35 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// wantErrDetail is the normalized comparison shape for a typed error's wire
|
||||
// fields: Type is the error's Category string ("validation", "config", ...),
|
||||
// alongside Message and Hint.
|
||||
type wantErrDetail struct {
|
||||
Type string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// assertExitError checks the full structured error in one assertion against a
|
||||
// typed error (ValidationError or ConfigError), normalizing its Category /
|
||||
// Message / Hint to wantDetail.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed errors (ValidationError, ConfigError) — they normalize to the same
|
||||
// wantDetail fields. The wantDetail.Type is matched against the typed error's
|
||||
// Category string ("validation", "config", etc.).
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
@@ -53,13 +59,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDe
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if valErr.Param != "--lang" {
|
||||
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
||||
})
|
||||
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
// TestFactory has IsTerminal=false by default
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify OpenClaw is installed and configured",
|
||||
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify lark-channel-bridge is installed and configured",
|
||||
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.id missing in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -835,19 +835,17 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
// The workspace name stays out of the wire subtype; it only appears in
|
||||
// the message.
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
@@ -916,6 +914,25 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "feishu"},
|
||||
{"feishu", "feishu"},
|
||||
{"lark", "lark"},
|
||||
{"LARK", "lark"},
|
||||
{" lark ", "lark"},
|
||||
{"Lark", "lark"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeBrand(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
||||
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
||||
custom := filepath.Join(t.TempDir(), "custom.json")
|
||||
@@ -1170,7 +1187,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||
base := wantErrDetail{
|
||||
base := output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
@@ -1186,7 +1203,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||
got, wantWorkFirst, wantPersonalFirst)
|
||||
@@ -1213,7 +1230,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
@@ -1233,7 +1250,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||
})
|
||||
@@ -1519,7 +1536,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1539,7 +1556,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1565,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
@@ -1593,7 +1610,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
@@ -1655,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
|
||||
@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.ParseBrand(selected.Brand),
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -350,6 +350,16 @@ func sourceDisplayName(source string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
|
||||
@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
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.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// 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.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
|
||||
@@ -6,7 +6,6 @@ package config
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,38 +19,21 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
initCmd := NewCmdConfigInit(f, nil)
|
||||
bind := NewCmdConfigBind(f, nil)
|
||||
remove := NewCmdConfigRemove(f, nil)
|
||||
show := NewCmdConfigShow(f, nil)
|
||||
defaultAs := NewCmdConfigDefaultAs(f)
|
||||
strictMode := NewCmdConfigStrictMode(f)
|
||||
riskControl := NewCmdConfigRiskControl(f)
|
||||
policy := NewCmdConfigPolicy(f)
|
||||
plugins := NewCmdConfigPlugins(f)
|
||||
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
|
||||
// Identity preferences live in the Profile, but external providers have
|
||||
// historically treated these config commands as credential management.
|
||||
// Check Profile ownership first so a managed runtime gives the actionable
|
||||
// deployment-managed Profile error, then retain the credential capability
|
||||
// so Standard external-provider behavior stays unchanged.
|
||||
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
|
||||
cmdutil.SetRuntimeCapabilities(
|
||||
identitySetting,
|
||||
runtimeplan.CapabilityLocalProfileMutation,
|
||||
runtimeplan.CapabilityLocalCredentialManagement,
|
||||
)
|
||||
}
|
||||
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
|
||||
cmdutil.SetRuntimeCapabilities(sourceNeutral)
|
||||
}
|
||||
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -94,16 +92,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if valErr.Param != "--lang" {
|
||||
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -393,38 +385,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
// A name/appId conflict is user input — a typed validation error naming the
|
||||
// offending flag, not a system storage failure.
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if verr.Param != "--name" {
|
||||
t.Errorf("param = %q, want --name", verr.Param)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "conflicts with existing appId") {
|
||||
t.Errorf("message = %q, want conflict description", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
|
||||
// validation error (e.g. the --name conflict) is not reclassified as an
|
||||
// internal storage failure on its way up through the save call sites.
|
||||
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
|
||||
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(wrapSaveConfigError(conflict), &verr) {
|
||||
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("untyped failure must become internal/storage")
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,16 +415,10 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
||||
type stubConfigExtProvider struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
type stubConfigExtProvider struct{ name string }
|
||||
|
||||
func (s *stubConfigExtProvider) Name() string { return s.name }
|
||||
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
@@ -488,6 +444,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"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"}},
|
||||
}
|
||||
@@ -515,63 +472,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
|
||||
profileDenied := errors.New("Profile identity settings are deployment-managed")
|
||||
credentialChecks := 0
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
switch capability {
|
||||
case runtimeplan.CapabilityLocalProfileMutation:
|
||||
return profileDenied
|
||||
case runtimeplan.CapabilityLocalCredentialManagement:
|
||||
credentialChecks++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"default-as", "bot"},
|
||||
{"strict-mode", "bot"},
|
||||
} {
|
||||
t.Run(args[0], func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if !errors.Is(err, profileDenied) {
|
||||
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if credentialChecks != 0 {
|
||||
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := NewCmdConfig(f)
|
||||
|
||||
for _, name := range []string{"default-as", "strict-mode"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
leaf, _, err := root.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := cmdutil.GetRuntimeCapabilities(leaf)
|
||||
want := []runtimeplan.Capability{
|
||||
runtimeplan.CapabilityLocalProfileMutation,
|
||||
runtimeplan.CapabilityLocalCredentialManagement,
|
||||
}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
||||
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
||||
// the same locale; an unrecognized value errors.
|
||||
|
||||
@@ -6,11 +6,13 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -125,9 +127,12 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
|
||||
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
@@ -178,20 +183,6 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
|
||||
}
|
||||
|
||||
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
|
||||
// validation error from saveAsProfile) through unchanged, and classifies any
|
||||
// other failure as an internal storage error. Without the passthrough a user
|
||||
// input error would surface to agents as a system storage failure.
|
||||
func wrapSaveConfigError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
@@ -216,9 +207,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"profile name %q conflicts with existing appId", profileName).
|
||||
WithParam("--name")
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
@@ -260,8 +249,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
// wrapUpdateExistingProfileErr classifies the error returned by
|
||||
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
|
||||
// for blank-input) pass through unchanged so their exit code semantics
|
||||
// survive; everything else (filesystem, keychain, etc.) is wrapped as
|
||||
// InternalError.
|
||||
// survive; legacy *output.ExitError also passes through; everything else
|
||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -269,6 +258,10 @@ func wrapUpdateExistingProfileErr(err error) error {
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -343,14 +336,11 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -360,7 +350,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
return langSelectionError(err)
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
@@ -383,13 +376,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -413,7 +403,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
@@ -429,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -518,14 +503,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -26,15 +26,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw") {
|
||||
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
@@ -51,15 +48,12 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "hermes") {
|
||||
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -182,9 +180,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, classifyRegistrationBeginError(err)
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
@@ -210,17 +208,33 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
// Step 4: Poll for credentials (brand discovery lives in internal/auth);
|
||||
// this layer only classifies the terminal error and saves the result.
|
||||
result, finalBrand, err := larkauth.RegisterAppWithDiscovery(ctx, httpClient, authResp, f.IOStreams.ErrOut)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, classifyRegistrationError(err)
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
finalBrand := larkBrand
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
||||
|
||||
@@ -231,40 +245,3 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
AppSecret: result.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classifyRegistrationBeginError keeps transport/cancellation failures out of
|
||||
// the invalid-client category: the begin request sends no app credentials.
|
||||
func classifyRegistrationBeginError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration cancelled").WithCause(err)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "app registration begin timed out: %v", err).WithCause(err)
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if netErr.Timeout() {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// classifyRegistrationError maps registration terminal outcomes to typed
|
||||
// errors, preserving causes.
|
||||
func classifyRegistrationError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, larkauth.ErrRegistrationDenied):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and approve the authorization request").
|
||||
WithCause(err)
|
||||
case errors.Is(err, larkauth.ErrRegistrationExpired), errors.Is(err, larkauth.ErrRegistrationTimedOut):
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenExpired, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and complete the scan before the code expires").
|
||||
WithCause(err)
|
||||
default:
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
func assertRegistrationProblem(t *testing.T, got, cause error, category errs.Category, subtype errs.Subtype) *errs.Problem {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("error %T is not typed: %v", got, got)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Errorf("problem = (%q, %q), want (%q, %q)", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Errorf("error %v does not preserve cause %v", got, cause)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationBeginError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{"cancelled", context.Canceled, errs.CategoryAuthentication, errs.SubtypeUnknown},
|
||||
{"deadline", context.DeadlineExceeded, errs.CategoryNetwork, errs.SubtypeNetworkTimeout},
|
||||
{"transport", &net.DNSError{Err: "lookup failed", Name: "accounts.example"}, errs.CategoryNetwork, errs.SubtypeNetworkTransport},
|
||||
{"response", errors.New("response not JSON"), errs.CategoryAPI, errs.SubtypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assertRegistrationProblem(t, classifyRegistrationBeginError(tt.err), tt.err, tt.category, tt.subtype)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
hint bool
|
||||
}{
|
||||
{"denied", larkauth.ErrRegistrationDenied, errs.SubtypeUnknown, true},
|
||||
{"expired", larkauth.ErrRegistrationExpired, errs.SubtypeTokenExpired, true},
|
||||
{"timed-out", larkauth.ErrRegistrationTimedOut, errs.SubtypeTokenExpired, true},
|
||||
{"cancelled", context.Canceled, errs.SubtypeUnknown, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := assertRegistrationProblem(t, classifyRegistrationError(tt.err), tt.err, errs.CategoryAuthentication, tt.subtype)
|
||||
if (p.Hint != "") != tt.hint {
|
||||
t.Errorf("hint = %q, want non-empty=%v", p.Hint, tt.hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
@@ -101,12 +97,3 @@ func promptLangSelection() (i18n.Lang, error) {
|
||||
}
|
||||
return lang, nil
|
||||
}
|
||||
|
||||
// langSelectionError maps a promptLangSelection failure to its exit surface:
|
||||
// user abort exits bare with code 1; any other failure is internal.
|
||||
func langSelectionError(err error) error {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
}
|
||||
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
|
||||
|
||||
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
||||
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
||||
// exit semantics: a typed ValidationError must keep ExitValidation rather than
|
||||
// being downgraded to InternalError.
|
||||
// exit semantics (regression: typed ValidationError was being downgraded to
|
||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
@@ -90,6 +90,18 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
|
||||
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
|
||||
}
|
||||
if exitErr.Code != 7 {
|
||||
t.Errorf("Code = %d, want 7", exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
||||
in := fmt.Errorf("disk full")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
|
||||
@@ -27,6 +27,13 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
|
||||
@@ -16,6 +16,12 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
|
||||
@@ -132,16 +132,19 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The policy group explicitly overrides the config parent's local credential
|
||||
// management capability because it is source-neutral diagnostics.
|
||||
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
root := NewCmdConfig(f)
|
||||
leaf, _, err := root.Find([]string{"policy", "show"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
|
||||
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,6 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
// config show describes the effective invocation configuration, not merely
|
||||
// the bytes in config.json. Preserve the typed bootstrap failure so a
|
||||
// Standard binary cannot present a local Profile as active when the system
|
||||
// requires Extended runtime support.
|
||||
if startupErr := f.RuntimeStartupError(); startupErr != nil {
|
||||
return startupErr
|
||||
}
|
||||
if handled, editionErr := showEditionConfig(f); handled {
|
||||
return editionErr
|
||||
}
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type editionConfigShowResult struct {
|
||||
Source string `json:"source"`
|
||||
CredentialProvider string `json:"credentialProvider"`
|
||||
Manageable bool `json:"manageable"`
|
||||
Workspace string `json:"workspace"`
|
||||
AppID string `json:"appId"`
|
||||
Brand string `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Profile *string `json:"profile,omitempty"`
|
||||
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
|
||||
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
|
||||
}
|
||||
|
||||
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(context.Background())
|
||||
if err != nil {
|
||||
return true, typedEditionProviderError("determine the active credential provider", err)
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return false, nil
|
||||
}
|
||||
if source.AppID == "" {
|
||||
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"external credential provider %q returned no account", source.Name)
|
||||
}
|
||||
result := editionConfigShowResult{
|
||||
Source: "external",
|
||||
CredentialProvider: source.Name,
|
||||
Manageable: false,
|
||||
Workspace: core.CurrentWorkspace().Display(),
|
||||
AppID: source.AppID,
|
||||
Brand: string(source.Brand),
|
||||
DefaultAs: string(source.DefaultAs),
|
||||
}
|
||||
description := f.RuntimeDescription()
|
||||
if source.ProfileName != "" {
|
||||
result.Profile = &source.ProfileName
|
||||
}
|
||||
if description.Managed && description.Variant != "" {
|
||||
result.ExternalCredentialMode = &description.Variant
|
||||
if description.ProxiesRequests {
|
||||
result.RemoteEndpoint = &description.DataPlaneEndpoint
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func typedEditionProviderError(action string, err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
cmd := NewCmdConfig(f)
|
||||
matched, _, err := cmd.Find([]string{"show"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
|
||||
t.Fatalf("config show blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
var stdout bytes.Buffer
|
||||
f.IOStreams.Out = &stdout
|
||||
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
|
||||
Description: runtimeplan.Description{
|
||||
Managed: true,
|
||||
Variant: "managed-test",
|
||||
ProxiesRequests: true,
|
||||
DataPlaneEndpoint: "https://managed.example.test",
|
||||
},
|
||||
}))
|
||||
|
||||
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("configShowRun() error = %v", err)
|
||||
}
|
||||
var got editionConfigShowResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "external" ||
|
||||
got.CredentialProvider != "env" ||
|
||||
got.Manageable ||
|
||||
got.AppID != "test-app" ||
|
||||
got.ExternalCredentialMode == nil ||
|
||||
*got.ExternalCredentialMode != "managed-test" ||
|
||||
got.RemoteEndpoint == nil ||
|
||||
*got.RemoteEndpoint != "https://managed.example.test" {
|
||||
t.Fatalf("output = %#v", got)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := fields["appSecret"]; ok {
|
||||
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
|
||||
}
|
||||
if _, ok := fields["users"]; ok {
|
||||
t.Fatalf("managed output must not invent users: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
providerErr := errors.New("provider failed")
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("error = %#v, want internal/unknown", err)
|
||||
}
|
||||
if !errors.Is(err, providerErr) {
|
||||
t.Fatalf("error does not preserve provider failure: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package config
|
||||
|
||||
import "github.com/larksuite/cli/internal/cmdutil"
|
||||
|
||||
func showEditionConfig(*cmdutil.Factory) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok ||
|
||||
problem.Category != errs.CategoryConfig ||
|
||||
problem.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("error = %#v, want established config/not_configured result", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
|
||||
startupErr := errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"system external credential configuration requires the lark-cli Extended edition",
|
||||
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
|
||||
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
|
||||
t,
|
||||
nil,
|
||||
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
|
||||
)
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if !errors.Is(err, startupErr) {
|
||||
t.Fatalf("config show error = %v, want original startup error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok ||
|
||||
problem.Category != errs.CategoryValidation ||
|
||||
problem.Subtype != errs.SubtypeFailedPrecondition ||
|
||||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
|
||||
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -84,10 +83,6 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
if handled, editionErr := runEditionDoctor(opts, checks); handled {
|
||||
return editionErr
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -99,7 +94,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
@@ -113,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
@@ -133,9 +128,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant.
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
@@ -218,7 +211,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := fetchLatestForEdition()
|
||||
latest, err := update.FetchLatest()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
|
||||
f := opts.Factory
|
||||
if f == nil || f.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(opts.Ctx)
|
||||
if err != nil {
|
||||
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
provider := source.Name
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
checks = append(checks,
|
||||
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
|
||||
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
|
||||
)
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("credential_source",
|
||||
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
|
||||
|
||||
description := f.RuntimeDescription()
|
||||
if description.Managed {
|
||||
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
|
||||
} else {
|
||||
checks = append(checks, skip("config_file",
|
||||
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
|
||||
}
|
||||
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
|
||||
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
if description.ProxiesRequests {
|
||||
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
|
||||
} else {
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
|
||||
}
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func editionDiagnosticErrorHint(err error) string {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
return blockErr.Reason
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return cfgErr.Hint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
|
||||
if opts.Offline {
|
||||
return skip("endpoint_external_platform", "skipped (--offline)")
|
||||
}
|
||||
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
|
||||
if verified(diagnostics.User) || verified(diagnostics.Bot) {
|
||||
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
|
||||
}
|
||||
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
|
||||
"check the external credential program and platform logs")
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/identitydiag"
|
||||
)
|
||||
|
||||
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
|
||||
verified := true
|
||||
endpoint := "https://credentials.example.com"
|
||||
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
|
||||
User: identitydiag.Identity{Verified: &verified},
|
||||
})
|
||||
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
|
||||
t.Fatalf("check = %#v", got)
|
||||
}
|
||||
|
||||
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
|
||||
if got.Status != "fail" {
|
||||
t.Fatalf("unverified check = %#v, want fail", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
|
||||
}
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_env",
|
||||
SupportedIdentities: extcred.SupportsBot,
|
||||
},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "credential_source", "pass")
|
||||
configCheck := findCheck(t, got.Checks, "config_file")
|
||||
if configCheck.Status != "skip" ||
|
||||
!strings.Contains(configCheck.Message, "local config") ||
|
||||
strings.Contains(configCheck.Message, "config init") {
|
||||
t.Fatalf("config_file = %#v", configCheck)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
|
||||
if opts == nil || opts.Factory == nil {
|
||||
return false, nil
|
||||
}
|
||||
startupErr := opts.Factory.RuntimeStartupError()
|
||||
if startupErr == nil {
|
||||
return false, nil
|
||||
}
|
||||
hint := ""
|
||||
if problem, ok := errs.ProblemOf(startupErr); ok {
|
||||
hint = problem.Hint
|
||||
}
|
||||
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
|
||||
return true, finishDoctor(opts.Factory, checks)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
|
||||
}
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_env",
|
||||
SupportedIdentities: extcred.SupportsBot,
|
||||
},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatal("doctorRun() = nil, want established missing-config failure")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCheck(t, got.Checks, "config_file", "fail")
|
||||
for _, check := range got.Checks {
|
||||
if check.Name == "credential_source" {
|
||||
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,12 @@ package doctor
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -144,118 +140,14 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
if got := findCheck(t, checks, name); got.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
return check
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
return checkResult{}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type failingDefaultAccountResolver struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatal("doctorRun() = nil, want not-configured failure")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
configCheck := findCheck(t, got.Checks, "config_file")
|
||||
if configCheck.Status != "fail" {
|
||||
t.Fatalf("config_file = %#v, want fail", configCheck)
|
||||
}
|
||||
for _, check := range got.Checks {
|
||||
if check.Name == "credential_source" {
|
||||
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = cred
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
ready := findCheck(t, got.Checks, "identity_ready")
|
||||
if ready.Status != "fail" {
|
||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||
}
|
||||
// The summary defers to the per-identity checks; it carries no hint of its
|
||||
// own (a command here would be wrong under an external provider).
|
||||
if ready.Hint != "" {
|
||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||
}
|
||||
user := findCheck(t, got.Checks, "user_identity")
|
||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/internal/extendedupdate"
|
||||
|
||||
func fetchLatestForEdition() (string, error) {
|
||||
return extendedupdate.FetchLatest()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/internal/update"
|
||||
|
||||
func fetchLatestForEdition() (string, error) {
|
||||
return update.FetchLatest()
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
|
||||
clearWorkspaceSignals(t)
|
||||
clearCredentialSignals(t)
|
||||
configDir := t.TempDir()
|
||||
systemPath := filepath.Join(configDir, "external-credential.json")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root := Build(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), &stdout, &stderr),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
)
|
||||
root.SetArgs([]string{"doctor", "--offline"})
|
||||
if err := root.ExecuteContext(context.Background()); err == nil {
|
||||
t.Fatal("doctor returned nil, want failed diagnostic result")
|
||||
}
|
||||
|
||||
var report struct {
|
||||
Checks []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
} `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
||||
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
if check.Name != "credential_source" {
|
||||
continue
|
||||
}
|
||||
if check.Status != "fail" ||
|
||||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
|
||||
!strings.Contains(check.Hint, "install lark-cli Extended") {
|
||||
t.Fatalf("credential_source check = %#v", check)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "config init") {
|
||||
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
cmdversion "github.com/larksuite/cli/cmd/version"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// registerEditionCommands owns the Extended-only command surface.
|
||||
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
|
||||
root.AddCommand(cmdversion.NewCmdVersion(f))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExtendedRegistersVersionCommand(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
registerEditionCommands(root, f)
|
||||
|
||||
commands := root.Commands()
|
||||
if len(commands) != 1 || commands[0].Name() != "version" {
|
||||
t.Fatalf("Extended edition commands = %v, want [version]", commands)
|
||||
}
|
||||
if commands[0].Hidden {
|
||||
t.Fatal("Extended version command must be visible")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
cmdversion "github.com/larksuite/cli/cmd/version"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// registerEditionCommands keeps the release identity probe callable in
|
||||
// Standard while cmd/version hides it from help. This preserves the ordinary
|
||||
// command surface and gives installers/CI one edition-neutral verification
|
||||
// contract.
|
||||
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
|
||||
root.AddCommand(cmdversion.NewCmdVersion(f))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
registerEditionCommands(root, f)
|
||||
|
||||
commands := root.Commands()
|
||||
if len(commands) != 1 || commands[0].Name() != "version" {
|
||||
t.Fatalf("Standard edition commands = %v, want [version]", commands)
|
||||
}
|
||||
if !commands[0].Hidden {
|
||||
t.Fatal("Standard version command must remain hidden")
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"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/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -48,6 +48,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
authErr.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||
// need_user_authorization marker AND the current command declares scopes
|
||||
// locally.
|
||||
//
|
||||
// Deprecated: enrichment for the legacy envelope; the typed path is
|
||||
// applyNeedAuthorizationHint above.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
exitErr.Detail.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
@@ -92,37 +118,38 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
return nil
|
||||
}
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
return path
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
// 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.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -4,117 +4,21 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||
const (
|
||||
addonsLandingPath = "/page/launcher"
|
||||
addonsClientIDParam = "clientID"
|
||||
)
|
||||
|
||||
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
|
||||
// Encoded form: JSON -> gzip -> base64url(no padding).
|
||||
type ManifestAddons struct {
|
||||
Scopes *AddonsScopes `json:"scopes,omitempty"`
|
||||
Events *AddonsEvents `json:"events,omitempty"`
|
||||
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
|
||||
}
|
||||
|
||||
type AddonsScopes struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsEvents struct {
|
||||
Items AddonsEventItems `json:"items"`
|
||||
}
|
||||
|
||||
type AddonsEventItems struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsCallbacks struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||
func encodeAddons(a ManifestAddons) (string, error) {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 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%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
// 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%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
} else {
|
||||
s.User = missing
|
||||
}
|
||||
return ManifestAddons{Scopes: s}
|
||||
}
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||
if identity.IsBot() {
|
||||
ev.Items.Tenant = missing
|
||||
} else {
|
||||
ev.Items.User = missing
|
||||
}
|
||||
return ManifestAddons{Events: ev}
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
|
||||
@@ -4,109 +4,33 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
t.Helper()
|
||||
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode: %v", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip: %v", err)
|
||||
}
|
||||
var a ManifestAddons
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
|
||||
encoded, err := encodeAddons(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
for _, r := range encoded {
|
||||
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
|
||||
}
|
||||
}
|
||||
out := decodeAddons(t, encoded)
|
||||
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
|
||||
t.Errorf("roundtrip mismatch: %+v", out)
|
||||
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 TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
}
|
||||
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||
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 TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d marshal: %v", i, err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("null")) {
|
||||
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -65,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
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. Bounded runs ignore stdin EOF.")
|
||||
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'). Bounded runs ignore stdin EOF.")
|
||||
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
|
||||
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,28 +146,14 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
// Callback subscriptions live in application/get, not app_versions; fetch the
|
||||
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
|
||||
// leave subscribedCallbacks nil so the callback precheck skips.
|
||||
var subscribedCallbacks []string
|
||||
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
|
||||
if cbErr != nil {
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
|
||||
} else {
|
||||
subscribedCallbacks = cbs
|
||||
}
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -198,9 +184,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -243,9 +228,6 @@ type preflightCtx struct {
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
@@ -278,87 +260,63 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
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.
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
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, " "))
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
// in the app's console 底账 — published app_versions for event subscriptions,
|
||||
// application/get subscribed_callbacks for callback subscriptions.
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subscribed []string
|
||||
noun := "event types"
|
||||
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
if pf.subscribedCallbacks == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.subscribedCallbacks
|
||||
noun = "callbacks"
|
||||
} else {
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.appVer.EventTypes
|
||||
}
|
||||
|
||||
have := make(map[string]bool, len(subscribed))
|
||||
for _, t := range subscribed {
|
||||
have[t] = true
|
||||
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 !have[t] {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires %s not subscribed in console: %s",
|
||||
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||
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 "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -370,25 +328,22 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
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
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
|
||||
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) {
|
||||
@@ -396,10 +351,7 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -418,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -17,31 +16,14 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
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,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
// This hook shadows root's PersistentPreRun, so preserve the matched
|
||||
// command for structured error and declared-scope hints.
|
||||
f.CurrentCommand = cmd
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
|
||||
|
||||
consume := NewCmdConsume(f)
|
||||
bus := NewCmdBus(f)
|
||||
list := NewCmdList(f)
|
||||
schema := NewCmdSchema(f)
|
||||
status := NewCmdStatus(f)
|
||||
stop := NewCmdStop(f)
|
||||
for _, local := range []*cobra.Command{list, schema, status, stop} {
|
||||
cmdutil.SetRuntimeCapabilities(local)
|
||||
}
|
||||
cmd.AddCommand(consume)
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(schema)
|
||||
cmd.AddCommand(status)
|
||||
cmd.AddCommand(stop)
|
||||
cmd.AddCommand(bus)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_runtime_event_test",
|
||||
AppSecret: "must-not-be-used",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"real-time events are unavailable in this runtime").
|
||||
WithHint("use a runtime that supports real-time events")
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
if capability == runtimeplan.CapabilityRealtimeEvents {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
|
||||
cmd := NewCmdEvents(f)
|
||||
args := []string{"consume", "guarded-before-event-lookup"}
|
||||
matched, _, err := cmd.Find(args)
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
cmd.SetArgs(args)
|
||||
requireExternalEventGuard(t, cmd.Execute())
|
||||
if f.CurrentCommand != matched {
|
||||
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
|
||||
cmd := NewCmdEvents(f)
|
||||
args := []string{"_bus"}
|
||||
matched, _, err := cmd.Find(args)
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
cmd.SetArgs(args)
|
||||
requireExternalEventGuard(t, cmd.Execute())
|
||||
if f.CurrentCommand != matched {
|
||||
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
|
||||
}
|
||||
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("event bus created runtime files before guard: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdEvents(f)
|
||||
|
||||
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
|
||||
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
|
||||
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
|
||||
}
|
||||
|
||||
wantRealtime := map[string]bool{
|
||||
"_bus": true,
|
||||
"consume": true,
|
||||
"list": false,
|
||||
"schema": false,
|
||||
"status": false,
|
||||
"stop": false,
|
||||
}
|
||||
children := make(map[string]*cobra.Command, len(wantRealtime))
|
||||
for _, child := range cmd.Commands() {
|
||||
name := child.Name()
|
||||
want, ok := wantRealtime[name]
|
||||
if !ok {
|
||||
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
|
||||
}
|
||||
children[name] = child
|
||||
got := cmdutil.GetRuntimeCapabilities(child)
|
||||
if want {
|
||||
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
|
||||
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
|
||||
}
|
||||
}
|
||||
if len(children) != len(wantRealtime) {
|
||||
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
|
||||
}
|
||||
|
||||
// Clearing the parent declaration must also clear both consumers. This
|
||||
// proves they inherit the fail-closed default instead of duplicating a
|
||||
// leaf annotation that future event commands could forget.
|
||||
cmdutil.SetRuntimeCapabilities(cmd)
|
||||
for _, name := range []string{"consume", "_bus"} {
|
||||
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
|
||||
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireExternalEventGuard(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed problem", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s",
|
||||
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
t.Fatalf("param = %q, want empty", validationErr.Param)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatal("hint is empty")
|
||||
}
|
||||
}
|
||||
@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
@@ -270,15 +197,15 @@ func TestExitForOrphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.BareError
|
||||
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.BareError); ok {
|
||||
if t, ok := target.(**output.BareError); ok {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,24 +10,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -38,13 +24,8 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -74,33 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"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 {
|
||||
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,22 +145,21 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := permErr.Hint
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
"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) {
|
||||
@@ -173,109 +174,3 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("expected skip (nil), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||
// console state: a required callback IS missing and must be reported,
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback when none are subscribed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user