mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
227 Commits
feat/lark-
...
v1.0.65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 | ||
|
|
d0cde9a414 | ||
|
|
075b34f9a3 | ||
|
|
3788405256 | ||
|
|
462358a746 | ||
|
|
ad4d3cb874 | ||
|
|
171778951d | ||
|
|
a6797ac2e4 | ||
|
|
d852ab311b | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 | ||
|
|
1dd0758091 | ||
|
|
4a5a669b1a | ||
|
|
ebb0b6fe73 | ||
|
|
5c0a36b2a6 | ||
|
|
21905b0ba1 | ||
|
|
602c788fd9 | ||
|
|
30b28cf17f | ||
|
|
297776ea66 | ||
|
|
5b0c3137e3 | ||
|
|
4c31323de1 | ||
|
|
8a268aa2d2 | ||
|
|
39d60cb706 | ||
|
|
d9330b7ab3 | ||
|
|
6b833257c7 | ||
|
|
ba51d4874e | ||
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 | ||
|
|
736b131cdf | ||
|
|
5efaf65aec | ||
|
|
0991da7446 | ||
|
|
80bea45c6a | ||
|
|
c775cb4360 | ||
|
|
824aa9edf8 | ||
|
|
9d4ae94394 | ||
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f | ||
|
|
1f2164c7c2 | ||
|
|
76f5419a0d | ||
|
|
c5b5aece33 | ||
|
|
d687a76c79 | ||
|
|
4a4c3344c8 | ||
|
|
c61acb5264 | ||
|
|
7eeb111a2d | ||
|
|
714da970d0 | ||
|
|
ed7fdd1a27 | ||
|
|
4464ba7660 | ||
|
|
bb03c8ac4d | ||
|
|
3feb70b32a | ||
|
|
64b1b3f3ed | ||
|
|
a0e83c7e59 | ||
|
|
297b2a222e | ||
|
|
80a5f30f4d | ||
|
|
cf35d1e499 | ||
|
|
fd16cf106b | ||
|
|
53076733ec | ||
|
|
a3bee13ca9 | ||
|
|
6217bd2c29 | ||
|
|
72c294712c | ||
|
|
37f4f899b2 | ||
|
|
c0730b46bf | ||
|
|
751092c8ef | ||
|
|
deb0bd9dd6 | ||
|
|
0fbfe68726 | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 | ||
|
|
a4a4bd6ee0 | ||
|
|
ac116e7ca3 | ||
|
|
5e6a3eb857 | ||
|
|
493b3cce95 | ||
|
|
abc0553f21 | ||
|
|
a82a486508 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b | ||
|
|
24ce3ec151 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef | ||
|
|
0aa9e96d18 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/internal/ @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/
|
||||
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
@@ -5,13 +5,12 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
@@ -72,6 +71,7 @@ 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
|
||||
@@ -80,10 +80,84 @@ 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=origin/main
|
||||
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 errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . ..
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
@@ -103,6 +177,7 @@ jobs:
|
||||
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.txt
|
||||
@@ -184,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -205,9 +280,12 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
@@ -254,6 +332,9 @@ 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:
|
||||
@@ -291,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -303,6 +384,8 @@ 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
|
||||
@@ -318,6 +401,8 @@ jobs:
|
||||
"${{ 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 }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
611
.github/workflows/semantic-review.yml
vendored
Normal file
611
.github/workflows/semantic-review.yml
vendored
Normal file
@@ -0,0 +1,611 @@
|
||||
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;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
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,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
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;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
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,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
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,6 +7,11 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -22,6 +27,9 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
@@ -35,6 +43,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
@@ -44,3 +54,4 @@ 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,24 +49,47 @@ linters:
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
||||
# the rules below.
|
||||
- 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
|
||||
- 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-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
|
||||
# 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
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -87,13 +110,12 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── 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
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
[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.
|
||||
# ── 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
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
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.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
426
CHANGELOG.md
426
CHANGELOG.md
@@ -2,6 +2,413 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -964,6 +1371,25 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
52
Makefile
52
Makefile
@@ -5,10 +5,25 @@ 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
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
# 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 integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -32,9 +47,15 @@ fmt-check:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test 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 -gcflags="all=-N -l" -count=1 \
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
@@ -46,7 +67,32 @@ examples-build:
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
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)
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
|
||||
@@ -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 | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## 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 --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -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 --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
49
affordance/README.md
Normal file
49
affordance/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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>`
|
||||
<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
|
||||
### <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".
|
||||
|
||||
## 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.
|
||||
19
affordance/contact.md
Normal file
19
affordance/contact.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## 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}}'
|
||||
```
|
||||
102
cmd/api/api.go
102
cmd/api/api.go
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -66,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
@@ -90,6 +104,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
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)")
|
||||
@@ -122,7 +137,13 @@ 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, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
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 (-)"},
|
||||
)
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
@@ -152,7 +173,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +219,13 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
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"},
|
||||
)
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -232,7 +262,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -242,7 +272,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 output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -262,7 +292,7 @@ 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 output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -271,46 +301,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
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 {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
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.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
return errs.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)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -11,19 +13,35 @@ import (
|
||||
"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 := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -51,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -66,6 +84,24 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -77,14 +113,25 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Error("expected 'success' in output")
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -106,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -119,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -185,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -204,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -223,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -240,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -265,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -296,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -310,8 +357,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
if !strings.Contains(stdout.String(), "u123") {
|
||||
t.Error("expected user_id in JSON output")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,11 +379,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": 230001, "msg": "no permission",
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(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
|
||||
@@ -336,12 +391,20 @@ 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(), "230001") {
|
||||
if !strings.Contains(stdout.String(), "230027") {
|
||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no permission") {
|
||||
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||
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) {
|
||||
@@ -361,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
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 {
|
||||
@@ -377,6 +440,274 @@ 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
|
||||
@@ -405,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -425,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -444,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -475,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -496,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -514,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -543,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(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 {
|
||||
@@ -564,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -583,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -601,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -618,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -635,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -658,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(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 {
|
||||
@@ -699,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -718,3 +1049,23 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,29 @@ 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)
|
||||
|
||||
@@ -109,6 +132,27 @@ 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)
|
||||
|
||||
@@ -126,6 +170,27 @@ 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,
|
||||
@@ -145,6 +210,29 @@ 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,
|
||||
@@ -267,6 +355,32 @@ 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -33,12 +33,9 @@ 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.ExitError
|
||||
var bare *output.BareError
|
||||
if !errors.As(err, &bare) {
|
||||
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)
|
||||
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +36,7 @@ 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
|
||||
@@ -44,12 +47,20 @@ 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 *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
@@ -61,6 +72,14 @@ 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,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ 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
|
||||
@@ -57,3 +85,48 @@ 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,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// 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).
|
||||
// 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.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ 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 returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// 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 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
dm := domainMeta{Name: name}
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
}
|
||||
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{"base", "contact", "docs", "markdown", "apps"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,12 @@ 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 {
|
||||
@@ -871,7 +878,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
|
||||
// ExitError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// BareError 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()
|
||||
@@ -938,16 +945,13 @@ 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.ExitError signal (no envelope).
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
if bareErr.Code != output.ExitAuth {
|
||||
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ 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
|
||||
@@ -44,25 +46,65 @@ 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
|
||||
}
|
||||
|
||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ 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)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ 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,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ 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
|
||||
|
||||
90
cmd/build.go
90
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -16,8 +17,11 @@ 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"
|
||||
@@ -31,9 +35,13 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -51,6 +59,18 @@ 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
|
||||
@@ -61,6 +81,41 @@ 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
|
||||
@@ -103,6 +158,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
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",
|
||||
@@ -115,6 +171,10 @@ 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
|
||||
@@ -135,20 +195,38 @@ 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))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, 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)
|
||||
}
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
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 {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
|
||||
46
cmd/build_test.go
Normal file
46
cmd/build_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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
|
||||
}
|
||||
160
cmd/cmdexample_catalog_test.go
Normal file
160
cmd/cmdexample_catalog_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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)]
|
||||
}
|
||||
60
cmd/cmdexample_check_test.go
Normal file
60
cmd/cmdexample_check_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// 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
|
||||
}
|
||||
222
cmd/cmdexample_parse_test.go
Normal file
222
cmd/cmdexample_parse_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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
|
||||
}
|
||||
113
cmd/cmdexample_test.go
Normal file
113
cmd/cmdexample_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
}
|
||||
233
cmd/cmdexample_units_test.go
Normal file
233
cmd/cmdexample_units_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
52
cmd/command_catalog_path_test.go
Normal file
52
cmd/command_catalog_path_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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,8 +4,7 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
case "powershell":
|
||||
return root.GenPowerShellCompletionWithDesc(out)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported shell: %s", args[0]).
|
||||
WithHint("supported shells: bash, zsh, fish, powershell")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
return "", langSelectionError(err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
|
||||
@@ -20,35 +20,29 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
gotDetail := wantErrDetail{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)
|
||||
}
|
||||
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
gotDetail := wantErrDetail{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 *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -835,17 +835,19 @@ 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 *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
// 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 !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
@@ -1187,7 +1189,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 := output.ErrDetail{
|
||||
base := wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
@@ -1203,7 +1205,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 := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
got := wantErrDetail{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)
|
||||
@@ -1230,7 +1232,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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
@@ -1250,7 +1252,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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||
})
|
||||
@@ -1536,7 +1538,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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1556,7 +1558,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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
@@ -1610,7 +1612,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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
|
||||
@@ -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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
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, output.ErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
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.",
|
||||
}
|
||||
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.")
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
@@ -183,6 +178,20 @@ 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.
|
||||
@@ -207,7 +216,9 @@ 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 fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"profile name %q conflicts with existing appId", profileName).
|
||||
WithParam("--name")
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
@@ -249,8 +260,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; legacy *output.ExitError also passes through; everything else
|
||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
||||
// survive; everything else (filesystem, keychain, etc.) is wrapped as
|
||||
// InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -258,10 +269,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -336,11 +343,14 @@ 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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(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
|
||||
}
|
||||
|
||||
@@ -350,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
return langSelectionError(err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
@@ -376,10 +383,13 @@ 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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(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
|
||||
}
|
||||
|
||||
@@ -403,7 +413,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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
@@ -419,6 +429,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -503,9 +518,14 @@ 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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return wrapSaveConfigError(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/internal/core"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
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 !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
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 {
|
||||
@@ -97,3 +101,12 @@ 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)
|
||||
}
|
||||
|
||||
92
cmd/config/init_probe.go
Normal file
92
cmd/config/init_probe.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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
|
||||
}
|
||||
287
cmd/config/init_probe_test.go
Normal file
287
cmd/config/init_probe_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// 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 (regression: typed ValidationError was being downgraded to
|
||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
||||
// exit semantics: a typed ValidationError must keep ExitValidation rather than
|
||||
// being downgraded to InternalError.
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
@@ -90,18 +90,6 @@ 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)
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *core.ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
@@ -128,7 +129,10 @@ 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 {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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) {
|
||||
@@ -140,14 +145,84 @@ 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 {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
return check
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 +48,6 @@ 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.
|
||||
@@ -118,38 +92,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
// 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.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// 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 {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
// 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...)
|
||||
}
|
||||
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)
|
||||
return path
|
||||
}
|
||||
|
||||
// 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 when adding brands in consoleScopeGrantURL.
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
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,6 +12,7 @@ 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"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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,21 +4,117 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
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
|
||||
}
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(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}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,109 @@
|
||||
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 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 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_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 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_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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
@@ -64,8 +65,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.")
|
||||
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().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().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
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,14 +146,28 @@ 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,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -184,8 +198,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// 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) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -228,6 +243,9 @@ 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).
|
||||
@@ -260,63 +278,87 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
// 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 {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, 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 current published version.
|
||||
// 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.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
|
||||
var 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
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
if !have[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,22 +370,25 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", 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 result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
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
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
@@ -351,7 +396,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +418,8 @@ 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,3 +61,70 @@ 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,9 +4,14 @@
|
||||
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) {
|
||||
@@ -73,6 +78,7 @@ 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 {
|
||||
@@ -90,6 +96,77 @@ 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
|
||||
@@ -130,6 +207,7 @@ 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 {
|
||||
|
||||
@@ -143,6 +143,79 @@ 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{
|
||||
@@ -197,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
var exit *output.BareError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
if e, ok := err.(*output.BareError); ok {
|
||||
if t, ok := target.(**output.BareError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,10 +10,22 @@ 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{
|
||||
"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"})
|
||||
|
||||
@@ -26,6 +38,9 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"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)
|
||||
@@ -55,4 +70,31 @@ 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{
|
||||
"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,19 +89,17 @@ 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)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
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.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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,21 +143,22 @@ 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 exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
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.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
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)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
@@ -174,3 +173,109 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
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 != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
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.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -95,6 +96,146 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["key"] != key {
|
||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||
}
|
||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||
}
|
||||
properties, ok := resolved["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||
}
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["end_time"]; ok {
|
||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
@@ -129,3 +270,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if bareErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -40,31 +40,65 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
// The offending flag is carried structurally on Params (replaces the
|
||||
// legacy detail map) and named in the message.
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
|
||||
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
|
||||
}
|
||||
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
|
||||
t.Error("Params[0].Reason must explain the rejection")
|
||||
}
|
||||
if !strings.Contains(verr.Message, "--rang") {
|
||||
t.Errorf("message should name the offending flag, got %q", verr.Message)
|
||||
}
|
||||
// The ranked candidate rides on the param as a machine-readable suggestion
|
||||
// so an agent can retry without parsing prose.
|
||||
if len(verr.Params) == 1 {
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
// The same candidate is also carried in the human-facing hint.
|
||||
if !strings.Contains(verr.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
|
||||
}
|
||||
|
||||
// Happy path: a valid policy.yml denies one specific command. The denied
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// command's RunE returns a typed error envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
@@ -127,13 +129,27 @@ max_risk: write
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// The denial taxonomy (reason_code, layer, rule) is preserved on the
|
||||
// wrapped *platform.CommandDeniedError cause and folded into the hint.
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("error chain should expose *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.ReasonCode != "command_denylisted" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "command_denylisted") {
|
||||
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
|
||||
}
|
||||
|
||||
// im/+send must be denied (domain not in Allow).
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
@@ -34,16 +34,8 @@ import (
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
|
||||
// which is part of the legacy error surface that predates the typed error
|
||||
// contract introduced by errs/. New code MUST NOT add new callers — the
|
||||
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
|
||||
// when the platform-extension framework migrates. This wrapper is retained
|
||||
// only for the existing in-tree call sites; it will be removed once they
|
||||
// have moved to the typed surface.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// typed error each time.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
// Deprecated: installPluginInstallErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin install failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
// failure as a typed validation error (failed_precondition) before any
|
||||
// command runs.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
makeErr := func() error {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: pi.Error(),
|
||||
Detail: map[string]any{
|
||||
"plugin": pi.PluginName,
|
||||
"reason_code": pi.ReasonCode,
|
||||
"reason": pi.Reason,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: installErr.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": internalplatform.ReasonInstallFailed,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
|
||||
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
|
||||
WithCause(installErr)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
|
||||
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
|
||||
WithCause(installErr)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
||||
// Restrict). The design separates the envelope type:
|
||||
// Restrict). The hint separates the two failure modes by reason code:
|
||||
//
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
// - "invalid_rule" - single bad rule
|
||||
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
|
||||
// via its internal makeErr lambda. New code MUST NOT add such producers —
|
||||
// plugin conflict failures should surface as a typed *errs.XxxError once the
|
||||
// platform-extension framework migrates. This helper is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
|
||||
WithCause(err)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin lifecycle failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
// on-call) can tell the two failure modes apart.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
makeErr := func() error {
|
||||
reasonCode := "lifecycle_failed"
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
hookName := ""
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
hookName = le.HookName
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithCause(err)
|
||||
if hookName != "" {
|
||||
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
|
||||
}
|
||||
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
||||
// of the legacy error surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT add new callers — the platform-
|
||||
// extension guard plumbing will switch to typed errs.* errors when the
|
||||
// platform-extension framework migrates. This wrapper is retained only for
|
||||
// the existing in-tree call sites; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() error) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
// checkGuardError asserts that err is the typed validation error the
|
||||
// install guard produces: a failed_precondition *errs.ValidationError
|
||||
// (exit 2) whose message + hint preserve the plugin name and the
|
||||
// install_failed reason code (the recovery info that lived in the legacy
|
||||
// detail map).
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
if !strings.Contains(verr.Hint, "policy") {
|
||||
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
|
||||
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
// The namespaced hook name and the abort semantics are preserved in the
|
||||
// message so a caller can identify which plugin hook rejected the call.
|
||||
if !strings.Contains(verr.Message, "policy-plugin.policy") {
|
||||
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "aborted") {
|
||||
t.Errorf("message should describe the abort, got %q", verr.Message)
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code multiple_restrict_plugins is folded into the hint so the
|
||||
// operator can distinguish a multi-Restrict conflict from a bad rule.
|
||||
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code invalid_rule is folded into the hint, distinct from the
|
||||
// multiple_restrict_plugins conflict path.
|
||||
if !strings.Contains(verr.Hint, "invalid_rule") {
|
||||
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
|
||||
// hook name are folded into the hint so audit / on-call can tell the
|
||||
// failure mode and which hook failed.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_failed") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "lc.start") {
|
||||
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// A panicking startup hook is distinguished from a returned error by
|
||||
// reason_code lifecycle_panic in the hint.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_panic") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
// The recovered panic surfaces as a structured error naming the
|
||||
// namespaced hook (p.boom) and describing the panic, so the process
|
||||
// never crashes and the caller can attribute the failure.
|
||||
if !strings.Contains(verr.Message, "p.boom") {
|
||||
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
// A panic in the wrapper FACTORY (not just the inner handler) is
|
||||
// recovered into the same structured panic error, naming the
|
||||
// namespaced hook fac.bad-factory.
|
||||
if !strings.Contains(verr.Message, "fac.bad-factory") {
|
||||
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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/i18n"
|
||||
@@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
|
||||
WithCause(err).
|
||||
WithParam("--name")
|
||||
}
|
||||
|
||||
langPref, err := cmdutil.ParseLangFlag(lang)
|
||||
@@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
|
||||
WithHint("use --app-secret-stdin and pipe the secret").
|
||||
WithParam("--app-secret-stdin")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
|
||||
WithCause(err).
|
||||
WithParam("--app-secret-stdin")
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
|
||||
WithHint("pipe the app secret to stdin").
|
||||
WithParam("--app-secret-stdin")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
|
||||
WithHint("pipe a non-empty app secret to stdin").
|
||||
WithParam("--app-secret-stdin")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return output.ErrValidation("profile %q already exists", name)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
|
||||
WithHint("choose a different name, or remove the existing profile first").
|
||||
WithParam("--name")
|
||||
}
|
||||
|
||||
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||
// multiple profiles sharing the same appId would collide on credentials.
|
||||
for _, a := range multi.Apps {
|
||||
if a.AppId == appID {
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
|
||||
WithParam("--app-id")
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
@@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
@@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "failed to load config") {
|
||||
t.Fatalf("error = %v, want failed to load config", err)
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
|
||||
@@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --lang ZH, got nil")
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
if internalErr.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
if internalErr.Cause == nil {
|
||||
t.Fatalf("cause = nil, want wrapped underlying error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
if !strings.Contains(internalErr.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError asserts err is a typed *errs.ValidationError with the
|
||||
// given subtype, message fragment, and exit code 2.
|
||||
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
|
||||
}
|
||||
if valErr.Subtype != wantSubtype {
|
||||
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
return valErr
|
||||
}
|
||||
|
||||
func saveTwoProfiles(t *testing.T) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAddRun_ValidationErrors(t *testing.T) {
|
||||
t.Run("invalid profile name", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
|
||||
if valErr.Param != "--name" {
|
||||
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
|
||||
}
|
||||
if valErr.Cause == nil {
|
||||
t.Fatal("cause = nil, want wrapped validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
|
||||
if valErr.Param != "--app-secret-stdin" {
|
||||
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
|
||||
}
|
||||
if valErr.Hint == "" {
|
||||
t.Fatal("hint is empty, want actionable hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty stdin", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("")
|
||||
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
|
||||
if valErr.Param != "--app-secret-stdin" {
|
||||
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank secret on stdin", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader(" \n")
|
||||
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
|
||||
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
||||
})
|
||||
|
||||
t.Run("duplicate profile name", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
|
||||
if valErr.Param != "--name" {
|
||||
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate app-id", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
|
||||
if valErr.Param != "--app-id" {
|
||||
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileUseRun_ValidationErrors(t *testing.T) {
|
||||
t.Run("no previous profile for toggle", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "-")
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
|
||||
if valErr.Hint == "" {
|
||||
t.Fatal("hint is empty, want actionable hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile not found", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "ghost")
|
||||
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
|
||||
t.Run("invalid new name", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "default", "bad name!")
|
||||
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
|
||||
if valErr.Cause == nil {
|
||||
t.Fatal("cause = nil, want wrapped validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("old profile not found", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "ghost", "fresh")
|
||||
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||
})
|
||||
|
||||
t.Run("new name already exists", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "default", "target")
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
|
||||
if valErr.Hint == "" {
|
||||
t.Fatal("hint is empty, want actionable hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
t.Run("profile not found", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveTwoProfiles(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "ghost")
|
||||
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||
})
|
||||
|
||||
t.Run("cannot remove the only profile", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "solo",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "solo")
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
|
||||
if valErr.Hint == "" {
|
||||
t.Fatal("hint is empty, want actionable hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileListRun(f)
|
||||
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
|
||||
if valErr.Cause == nil {
|
||||
t.Fatal("cause = nil, want wrapped load error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
|
||||
WithHint("add another profile first: lark-cli profile add")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/output"
|
||||
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
@@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
|
||||
WithHint("choose a different name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/output"
|
||||
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
|
||||
WithHint("switch to a profile by name first: lark-cli profile use <name>")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||
|
||||
27
cmd/prune.go
27
cmd/prune.go
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
// pick auth's instead of our denial. A leaf-level no-op makes
|
||||
// cobra stop here and proceed to the wrapped RunE.
|
||||
//
|
||||
// strict-mode keeps its short Message + independent Hint and
|
||||
// composes the shared detail.* / wrapped-CommandDeniedError shape
|
||||
// by hand; BuildDenialError would override Message with the
|
||||
// CommandDeniedError.Error() long form.
|
||||
// strict-mode keeps its short Message + independent Hint and wraps
|
||||
// the CommandDeniedError as the Cause by hand; BuildDenialError
|
||||
// would override Message with the CommandDeniedError.Error() long
|
||||
// form.
|
||||
stubMessage := fmt.Sprintf(
|
||||
"strict mode is %q, only %s-identity commands are available",
|
||||
mode, mode.ForcedIdentity())
|
||||
@@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
// Legacy *output.ExitError producer: this literal predates the
|
||||
// typed error contract introduced by errs/. New denial sites MUST
|
||||
// NOT construct *output.ExitError directly — they should return a
|
||||
// typed *errs.XxxError once the cmdpolicy framework migrates.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: stubMessage,
|
||||
Hint: stubHint,
|
||||
Detail: cmdpolicy.DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithCause(cd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the strict-mode envelope shape: structured detail.* / wrapped
|
||||
// CommandDeniedError for external agents, AND the historical short
|
||||
// Message + independent Hint for existing consumers.
|
||||
// Pins the strict-mode typed envelope: a failed_precondition
|
||||
// *errs.ValidationError (exit 2) carrying the short historical Message,
|
||||
// a Hint that still surfaces the policy layer + reason code (the
|
||||
// safety-critical recovery info that lived in the legacy detail map),
|
||||
// and the wrapped *platform.CommandDeniedError so external agents can
|
||||
// still inspect the structured denial taxonomy via errors.As.
|
||||
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
@@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
t.Fatalf("strict-mode stub RunE should return error")
|
||||
}
|
||||
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
t.Fatalf("err is not *output.ExitError: %T", err)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("err is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ee.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if ee.Detail.Type != "command_denied" {
|
||||
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
dm, ok := ee.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
|
||||
// Short historical Message is preserved verbatim.
|
||||
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
|
||||
t.Errorf("Message = %q, want short historical form", verr.Message)
|
||||
}
|
||||
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
|
||||
// The denial layer + reason code remain user-readable in the hint, and
|
||||
// the historical switch-policy guidance is still appended.
|
||||
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
|
||||
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
|
||||
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
|
||||
if !strings.Contains(verr.Hint, "identity_not_supported") {
|
||||
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
|
||||
}
|
||||
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
|
||||
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
|
||||
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
|
||||
}
|
||||
|
||||
// The structured denial taxonomy survives on the wrapped cause.
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
|
||||
@@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
if cd.ReasonCode != "identity_not_supported" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
|
||||
}
|
||||
if cd.PolicySource != "strict-mode" {
|
||||
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
|
||||
}
|
||||
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
|
||||
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
|
||||
}
|
||||
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
|
||||
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
|
||||
}
|
||||
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// strictModeStubFrom must write the denial annotations so the hook
|
||||
|
||||
667
cmd/root.go
667
cmd/root.go
@@ -11,18 +11,16 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -32,60 +30,71 @@ import (
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method> [--format pretty]
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
lark-cli calendar +agenda
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
More help: lark-cli <command> --help`
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -149,29 +158,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -202,100 +231,106 @@ func configureFlagCompletions(args []string) {
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
||||
// are promoted via errcompat to their typed errs/ counterparts, with the
|
||||
// original preserved in the Cause chain.
|
||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
|
||||
// typed envelope writer, which lifts extension fields (missing_scopes,
|
||||
// console_url, challenge_url, ...) to the top level. Routed by
|
||||
// errs.CategoryOf via ExitCodeOf.
|
||||
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
|
||||
// envelope, written via WriteErrorEnvelope.
|
||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
||||
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, challenge_url, ...) to the top level.
|
||||
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
|
||||
// constructed typed at their origin (internal/auth, internal/core), so the
|
||||
// dispatcher no longer promotes any legacy shape here.
|
||||
// 2. PartialFailure / BareError signals: the result envelope is already on
|
||||
// stdout; honor the exit code and write nothing to stderr.
|
||||
// 3. Residual cobra usage errors (missing required flag, unknown command,
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// Promote legacy error shapes into typed errs/ before envelope marshal.
|
||||
// NeedAuthorizationError check is first because it is the more specific
|
||||
// shape; *core.ConfigError check follows. errors.As preserves the original
|
||||
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
|
||||
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
|
||||
//
|
||||
// Outer-typed short-circuit: if err is already a typed *errs.* error,
|
||||
// skip PromoteXxxError so the producer's Subtype / Hint / extension
|
||||
// fields are not overwritten by a coarser promoted shape derived from a
|
||||
// legacy error buried in its Cause chain. Promotion is only for legacy
|
||||
// untyped entry points.
|
||||
if !isOuterTypedError(err) {
|
||||
var needAuthErr *internalauth.NeedAuthorizationError
|
||||
if errors.As(err, &needAuthErr) {
|
||||
err = errcompat.PromoteAuthError(needAuthErr)
|
||||
} else {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
err = errcompat.PromoteConfigError(cfgErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
||||
// (partial-write still returns true) so the exit code we read here is
|
||||
// preserved even if stderr is torn — torn stderr must not downgrade
|
||||
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
|
||||
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the legacy bridge below.
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
// preserve the original API error detail; skip enrichment
|
||||
// which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
|
||||
// stdout already carries the result; honor the requested exit code and
|
||||
// write nothing to stderr.
|
||||
var bareErr *output.BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
|
||||
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
|
||||
// to gate PromoteXxxError so a producer's outer typed envelope is never
|
||||
// overwritten by a coarser shape derived from its legacy Cause.
|
||||
func isOuterTypedError(err error) bool {
|
||||
_, ok := err.(errs.TypedError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// asExitError converts known structured error types to *output.ExitError.
|
||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError bridge.
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr
|
||||
}
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
@@ -326,44 +361,174 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — unknown-subcommand signals should move to
|
||||
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
|
||||
// agent-protocol metadata as typed extension fields. This helper is retained
|
||||
// only while existing dispatch sites are migrated; it will be removed once
|
||||
// they have moved to the typed surface.
|
||||
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
|
||||
// with a typed *errs.ValidationError: a flag that belongs to a missing
|
||||
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
|
||||
// each fail structured (exit 2) instead of degrading to help + exit 0.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
|
||||
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
|
||||
for _, flag := range unknown {
|
||||
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
|
||||
}
|
||||
return verr
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
|
||||
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
|
||||
for _, flag := range misplaced {
|
||||
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
|
||||
}
|
||||
return verr
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
suggestions := suggest.Closest(unknown, available, 6)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Record the offending subcommand and its ranked candidates as a param with
|
||||
// machine-readable Suggestions so an agent can retry without parsing the
|
||||
// hint; the hint carries the same candidates as prose.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -372,60 +537,100 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// Root command help groups, so an agent sees content domains, agent tooling, and
|
||||
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
||||
const (
|
||||
groupDomains = "lark-domains"
|
||||
groupTooling = "agent-tooling"
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case tooling[c.Name()]:
|
||||
c.GroupID = groupTooling
|
||||
case management[c.Name()]:
|
||||
c.GroupID = groupManagement
|
||||
case isLarkDomain(c):
|
||||
c.GroupID = groupDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
||||
return true
|
||||
}
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||
// structured but generic.
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
|
||||
// nothing) and the offending flag in `params`. Other flag errors stay typed
|
||||
// but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "flag_error",
|
||||
Message: ferr.Error(),
|
||||
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||
},
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": "--" + name,
|
||||
"command_path": c.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"valid_flags": valid,
|
||||
},
|
||||
},
|
||||
}
|
||||
// The ranked candidates ride on the param as machine-readable Suggestions so
|
||||
// an agent can retry without parsing the hint; the hint carries the same
|
||||
// candidates as prose. The full valid-flag list stays recoverable via --help.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
@@ -467,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
@@ -484,56 +700,3 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
|
||||
// Message + Hint match the per-subtype canonical text produced by the typed
|
||||
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
|
||||
// This guarantees a caller observing the wire envelope cannot tell whether
|
||||
// the error reached the dispatcher via the legacy *ExitError bridge or via
|
||||
// the typed *errs.PermissionError fast path.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
|
||||
// values produced by errclass.BuildAPIError already carry MissingScopes +
|
||||
// ConsoleURL directly.
|
||||
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
// Only the legacy permission-class envelope types route here. "app_status"
|
||||
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
|
||||
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
|
||||
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
|
||||
return
|
||||
}
|
||||
|
||||
larkCode := exitErr.Detail.Code
|
||||
meta, ok := errclass.LookupCodeMeta(larkCode)
|
||||
if !ok || meta.Category != errs.CategoryAuthorization {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract required scopes from API error detail (shared helper). May be
|
||||
// empty for app-status codes — canonical message + hint still apply.
|
||||
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse the same console URL builder as the typed path so both wire
|
||||
// envelopes carry identical console_url values for the same input.
|
||||
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
|
||||
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
||||
exitErr.Detail.Detail = nil
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
|
||||
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
|
||||
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -27,12 +26,12 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Canonical strict-mode envelope strings shared across fixtures
|
||||
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
||||
// Canonical strict-mode envelope messages shared across fixtures. The
|
||||
// switch-policy hint text is asserted by substring in
|
||||
// assertStrictModeDenialEnvelope.
|
||||
const (
|
||||
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
||||
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
||||
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
@@ -63,37 +62,46 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
// typedErrorEnvelope mirrors the typed wire shape produced by
|
||||
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
|
||||
// directly, so "type" is the category, "subtype" is top-level, and there
|
||||
// is no nested "detail" object. Recovery info (policy source, reason
|
||||
// code, suggestions) is folded into "hint".
|
||||
type typedErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
Param string `json:"param,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
|
||||
// legacy nested "detail" object is present (the migration removed it).
|
||||
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
if errObj, ok := raw["error"].(map[string]any); ok {
|
||||
if _, hasDetail := errObj["detail"]; hasDetail {
|
||||
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
var env typedErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
@@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "auth/login",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
// pruning). The typed envelope is a failed_precondition validation
|
||||
// error (exit 2); the strict-mode layer + reason code are folded into
|
||||
// the hint.
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
|
||||
}
|
||||
|
||||
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
|
||||
// a validation/failed_precondition envelope whose message is the short
|
||||
// historical strict-mode line and whose hint still names the strict_mode
|
||||
// layer + identity_not_supported reason code (the safety-critical recovery
|
||||
// info), plus the historical switch-policy guidance.
|
||||
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
|
||||
t.Helper()
|
||||
if env.OK {
|
||||
t.Errorf("envelope ok = true, want false")
|
||||
}
|
||||
if env.Error.Type != "validation" {
|
||||
t.Errorf("error.type = %q, want validation", env.Error.Type)
|
||||
}
|
||||
if env.Error.Subtype != "failed_precondition" {
|
||||
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
|
||||
}
|
||||
if env.Error.Message != wantMessage {
|
||||
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
|
||||
}
|
||||
if !strings.Contains(env.Error.Hint, "strict_mode") {
|
||||
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
|
||||
}
|
||||
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
|
||||
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
|
||||
}
|
||||
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
|
||||
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// assertCheckStrictModeEnvelope pins the typed envelope produced by
|
||||
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
|
||||
// --as on shortcuts / service methods / api): a *errs.ValidationError with
|
||||
// subtype invalid_argument, the canonical strict-mode message, and the
|
||||
// switch-policy hint.
|
||||
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
|
||||
t.Helper()
|
||||
if env.OK {
|
||||
t.Errorf("envelope ok = true, want false")
|
||||
}
|
||||
if env.Error.Type != "validation" {
|
||||
t.Errorf("error.type = %q, want validation", env.Error.Type)
|
||||
}
|
||||
if env.Error.Subtype != "invalid_argument" {
|
||||
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
|
||||
}
|
||||
if env.Error.Message != wantMessage {
|
||||
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
|
||||
}
|
||||
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
|
||||
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
@@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/+messages-search",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
||||
@@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
@@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
@@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: strictModeUserMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/images/create",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeUserMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
@@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
if code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
env := parseTypedEnvelope(t, stderr)
|
||||
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
@@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: typed error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var raw struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
if raw.OK {
|
||||
t.Errorf("envelope ok = true, want false")
|
||||
}
|
||||
if raw.Identity != "bot" {
|
||||
t.Errorf("identity = %q, want bot", raw.Identity)
|
||||
}
|
||||
if raw.Error.Type != "api" {
|
||||
t.Errorf("error.type = %q, want api", raw.Error.Type)
|
||||
}
|
||||
if raw.Error.Code != 230002 {
|
||||
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
|
||||
}
|
||||
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
|
||||
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
|
||||
346
cmd/root_test.go
346
cmd/root_test.go
@@ -21,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -75,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +139,6 @@ func TestIsCompletionCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_* lives with the implementation in
|
||||
// internal/errcompat/promote_test.go.
|
||||
|
||||
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
||||
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
||||
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
||||
@@ -268,6 +268,173 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
|
||||
// backward-compat alias failing on a cobra-level required flag (which
|
||||
// short-circuits before RunE) routes through the structured envelope, so the
|
||||
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
|
||||
// of being dropped on a plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
// The envelope is typed validation, so the exit code must derive from that
|
||||
// category (2) — the wire type and the exit code must not disagree.
|
||||
if exit != int(output.ExitValidation) {
|
||||
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
|
||||
// baseline for auth/config errors: it pins the typed envelope and exit code the
|
||||
// dispatcher produces for the two source-of-truth shapes, which are constructed
|
||||
// typed at their origin in internal/auth and internal/core.
|
||||
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "authentication" {
|
||||
t.Errorf("error.type = %v, want %q", got, "authentication")
|
||||
}
|
||||
if got := errObj["subtype"]; got != "token_missing" {
|
||||
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
|
||||
}
|
||||
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
|
||||
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
|
||||
}
|
||||
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
|
||||
t.Errorf("error.message = %q, must carry the user open id", got)
|
||||
}
|
||||
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
|
||||
t.Errorf("error.hint = %q, must point at auth login", got)
|
||||
}
|
||||
if got := errObj["user_open_id"]; got != "u_golden" {
|
||||
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, core.NotConfiguredError())
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "config" {
|
||||
t.Errorf("error.type = %v, want %q", got, "config")
|
||||
}
|
||||
if got := errObj["subtype"]; got != "not_configured" {
|
||||
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
|
||||
}
|
||||
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
|
||||
t.Errorf("error.message = %q, want the not-configured message", got)
|
||||
}
|
||||
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
|
||||
t.Errorf("error.hint = %q, must point at config init", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
|
||||
// top-level "error" object, failing the test if the shape is unexpected.
|
||||
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
|
||||
}
|
||||
errObj, ok := env["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing top-level error object: %s", raw)
|
||||
}
|
||||
return errObj
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
|
||||
// usage error (missing required flag) is typed as invalid_argument with exit 2
|
||||
// even with no deprecation pending — never cobra's plain "Error:" line.
|
||||
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "validation" {
|
||||
t.Errorf("error.type = %v, want %q", got, "validation")
|
||||
}
|
||||
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
|
||||
t.Errorf("error.message = %q, must carry the failing flag name", got)
|
||||
}
|
||||
if exit != int(output.ExitValidation) {
|
||||
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
||||
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
||||
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
||||
// not blamed on the user's input as a validation error.
|
||||
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "internal" {
|
||||
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
|
||||
}
|
||||
if exit != int(output.ExitInternal) {
|
||||
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
@@ -288,12 +455,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
|
||||
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
|
||||
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
|
||||
// would replace the producer's TokenExpired subtype + custom hint with the
|
||||
// promoted shape's TokenMissing.
|
||||
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
|
||||
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
|
||||
// contract: a *output.BareError is honored for its exit code while stderr stays
|
||||
// empty (stdout already carries the result, so the dispatcher must not layer a
|
||||
// second envelope on top).
|
||||
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
|
||||
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
|
||||
// Cause chain renders the producer's TokenExpired subtype + custom hint
|
||||
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
|
||||
// shape.
|
||||
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
@@ -445,136 +632,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
|
||||
// *output.ExitError dispatch path produces the same canonical Message + Hint
|
||||
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
|
||||
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
|
||||
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
|
||||
// the envelope.
|
||||
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
larkCode int
|
||||
legacyErrType string
|
||||
wantMsgSubstrs []string
|
||||
wantHintSubstrs []string
|
||||
wantConsoleURL bool
|
||||
wantNoAuthLogin bool // hint must not suggest `auth login`
|
||||
}{
|
||||
{
|
||||
name: "99991672 app_scope_not_applied",
|
||||
larkCode: 99991672,
|
||||
legacyErrType: "permission",
|
||||
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
|
||||
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
|
||||
wantConsoleURL: true,
|
||||
wantNoAuthLogin: true,
|
||||
},
|
||||
{
|
||||
name: "99991679 missing_scope",
|
||||
larkCode: 99991679,
|
||||
legacyErrType: "permission",
|
||||
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
|
||||
wantHintSubstrs: []string{"lark-cli auth login"},
|
||||
},
|
||||
{
|
||||
name: "99991673 app_unavailable",
|
||||
larkCode: 99991673,
|
||||
legacyErrType: "app_status",
|
||||
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
|
||||
wantHintSubstrs: []string{"tenant admin", "install status"},
|
||||
},
|
||||
{
|
||||
name: "99991662 app_disabled",
|
||||
larkCode: 99991662,
|
||||
legacyErrType: "app_status",
|
||||
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
|
||||
wantHintSubstrs: []string{"tenant admin", "re-enable"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
|
||||
// Detail.Type populated by ClassifyLarkError, Detail.Detail
|
||||
// carrying the permission_violations block so ExtractRequiredScopes
|
||||
// can recover the missing scope.
|
||||
scopeForDetail := "drive:drive:read"
|
||||
exitErr := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: tc.legacyErrType,
|
||||
Code: tc.larkCode,
|
||||
Message: "upstream raw message — must be replaced",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": scopeForDetail},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
|
||||
for _, sub := range tc.wantMsgSubstrs {
|
||||
if !strings.Contains(exitErr.Detail.Message, sub) {
|
||||
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
|
||||
}
|
||||
}
|
||||
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
|
||||
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
|
||||
}
|
||||
for _, sub := range tc.wantHintSubstrs {
|
||||
if !strings.Contains(exitErr.Detail.Hint, sub) {
|
||||
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
|
||||
}
|
||||
}
|
||||
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
|
||||
t.Error("ConsoleURL should be populated when missing scopes are present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
|
||||
// Detail.Type is neither "permission" nor "app_status" is left untouched —
|
||||
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
|
||||
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
|
||||
exitErr := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: ty,
|
||||
Code: 99991400,
|
||||
Message: "untouched",
|
||||
Hint: "original hint",
|
||||
},
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
if exitErr.Detail.Message != "untouched" {
|
||||
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.ConsoleURL != "" {
|
||||
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
cmd/root_upgrade.go
Normal file
90
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
||||
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
||||
// (install-method detection, output, error handling). Package-level var so
|
||||
// tests can stub it and avoid real network / self-update.
|
||||
var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
for _, c := range cmd.Root().Commands() {
|
||||
if c.Name() == "update" && c.RunE != nil {
|
||||
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
// AND no flag tokens in the raw invocation.
|
||||
func isBareRootInvocation(args []string) bool {
|
||||
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
||||
}
|
||||
|
||||
// readYes reads one line and reports whether it is an affirmative y/yes.
|
||||
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
||||
func readYes(r io.Reader) bool {
|
||||
line, _ := bufio.NewReader(r).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||
return
|
||||
}
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
runRootUpgrade(cmd)
|
||||
}
|
||||
|
||||
// installRootUpgradePrompt wraps the root command's RunE (set to
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
}
|
||||
191
cmd/root_upgrade_test.go
Normal file
191
cmd/root_upgrade_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYes(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
||||
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := readYes(strings.NewReader(in)); got != want {
|
||||
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBareRootInvocation(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
|
||||
rawInvocationArgs = nil
|
||||
if !isBareRootInvocation([]string{}) {
|
||||
t.Error("empty args + no raw flag tokens should be bare")
|
||||
}
|
||||
rawInvocationArgs = []string{"--profile", "x"}
|
||||
if isBareRootInvocation([]string{}) {
|
||||
t.Error("flag token present → not bare")
|
||||
}
|
||||
rawInvocationArgs = nil
|
||||
if isBareRootInvocation([]string{"im"}) {
|
||||
t.Error("positional arg → not bare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgrade(t *testing.T) {
|
||||
origV := build.Version
|
||||
build.Version = "1.0.0" // release version so shouldSkip()==false
|
||||
t.Cleanup(func() { build.Version = origV })
|
||||
|
||||
origRun := runRootUpgrade
|
||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||
|
||||
// This test builds a Factory literal (no NewDefault), so it never runs
|
||||
// workspace detection; pin the process-global workspace to Local so
|
||||
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
||||
// subdir inherited from a prior test in the package.
|
||||
origWS := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in, out, err bool
|
||||
input string
|
||||
latest string // "" → no state file (CheckCached nil)
|
||||
optOut bool
|
||||
wantPrompt, wantRun bool
|
||||
}{
|
||||
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
||||
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
||||
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
||||
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
||||
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
||||
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
||||
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
||||
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
||||
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
||||
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Clear env that update.shouldSkip treats as "suppress" so the
|
||||
// test is deterministic regardless of host (GitHub Actions sets
|
||||
// CI=true, which would otherwise suppress the prompt).
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILD_NUMBER", "")
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
if tc.latest != "" {
|
||||
writeUpdateState(t, dir, tc.latest)
|
||||
}
|
||||
if tc.optOut {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
called := false
|
||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(tc.input),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &errBuf,
|
||||
IsTerminal: tc.in,
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
rawInvocationArgs = nil
|
||||
|
||||
innerCalls := 0
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
||||
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
}
|
||||
if err := root.RunE(root, []string{"im"}); err != nil {
|
||||
t.Fatalf("non-bare RunE err = %v", err)
|
||||
}
|
||||
if innerCalls != 2 {
|
||||
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
||||
// path (not the stub used elsewhere): from any command it must locate the
|
||||
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
||||
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
ran := 0
|
||||
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
||||
child := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(child)
|
||||
|
||||
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
||||
|
||||
if ran != 1 {
|
||||
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
||||
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
||||
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,17 @@ package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,336 +24,10 @@ type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
}
|
||||
|
||||
func printServices(w io.Writer) {
|
||||
services := registry.ListFromMetaProjects()
|
||||
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
|
||||
for _, s := range services {
|
||||
spec := registry.LoadFromMeta(s)
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
|
||||
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
|
||||
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
danger := ""
|
||||
if d, _ := m["danger"].(bool); d {
|
||||
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
methodPath := registry.GetStrFromMap(method, "path")
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
httpColor := output.Yellow
|
||||
if httpMethod == "GET" {
|
||||
httpColor = output.Green
|
||||
} else if httpMethod == "DELETE" {
|
||||
httpColor = output.Red
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
|
||||
if desc != "" {
|
||||
fmt.Fprintf(w, " %s\n", desc)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Parameters
|
||||
params, _ := method["parameters"].(map[string]interface{})
|
||||
if len(params) > 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
for _, paramName := range sortedParamKeys(params) {
|
||||
p, _ := params[paramName].(map[string]interface{})
|
||||
pType := registry.GetStrFromMap(p, "type")
|
||||
if pType == "" {
|
||||
pType = "string"
|
||||
}
|
||||
location := registry.GetStrFromMap(p, "location")
|
||||
required, _ := p["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
locColor := output.Dim
|
||||
if location == "path" {
|
||||
locColor = output.Yellow
|
||||
}
|
||||
// Options (enum values)
|
||||
optStr := formatOptions(p)
|
||||
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
|
||||
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
|
||||
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
|
||||
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(p); rangeStr != "" {
|
||||
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --data for write methods
|
||||
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Response
|
||||
responseBody, _ := method["responseBody"].(map[string]interface{})
|
||||
if len(responseBody) > 0 {
|
||||
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
|
||||
printNestedFields(w, responseBody, " ", "")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Identity
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if s, ok := t.(string); ok {
|
||||
switch s {
|
||||
case "user":
|
||||
identities = append(identities, "user")
|
||||
case "tenant":
|
||||
identities = append(identities, "bot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(identities) > 0 {
|
||||
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes (all)
|
||||
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
|
||||
var scopeStrs []string
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok {
|
||||
scopeStrs = append(scopeStrs, str)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
|
||||
}
|
||||
|
||||
// CLI example
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
|
||||
for _, fieldName := range sortedFieldKeys(fields) {
|
||||
f, _ := fields[fieldName].(map[string]interface{})
|
||||
fullName := fieldName
|
||||
if prefix != "" {
|
||||
fullName = prefix + "." + fieldName
|
||||
}
|
||||
fType := registry.GetStrFromMap(f, "type")
|
||||
required, _ := f["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
optStr := formatOptions(f)
|
||||
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
|
||||
desc := registry.GetStrFromMap(f, "description")
|
||||
if desc != "" {
|
||||
desc = util.TruncateStrWithEllipsis(desc, 100)
|
||||
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
|
||||
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(f); rangeStr != "" {
|
||||
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
|
||||
printNestedFields(w, props, indent+" ", fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
|
||||
func formatOptions(f map[string]interface{}) string {
|
||||
opts, ok := f["options"].([]interface{})
|
||||
if !ok || len(opts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var vals []string
|
||||
for _, o := range opts {
|
||||
if om, ok := o.(map[string]interface{}); ok {
|
||||
if v := registry.GetStrFromMap(om, "value"); v != "" {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
|
||||
}
|
||||
|
||||
// formatRange returns "min..max" if field has min/max, else "".
|
||||
func formatRange(f map[string]interface{}) string {
|
||||
minVal := registry.GetStrFromMap(f, "min")
|
||||
maxVal := registry.GetStrFromMap(f, "max")
|
||||
if minVal == "" && maxVal == "" {
|
||||
return ""
|
||||
}
|
||||
if minVal != "" && maxVal != "" {
|
||||
return minVal + ".." + maxVal
|
||||
}
|
||||
if minVal != "" {
|
||||
return ">=" + minVal
|
||||
}
|
||||
return "<=" + maxVal
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in alphabetical order.
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
|
||||
func sortedParamKeys(params map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
pi, _ := params[keys[i]].(map[string]interface{})
|
||||
pj, _ := params[keys[j]].(map[string]interface{})
|
||||
ri, _ := pi["required"].(bool)
|
||||
rj, _ := pj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
|
||||
func sortedFieldKeys(fields map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
fi, _ := fields[keys[i]].(map[string]interface{})
|
||||
fj, _ := fields[keys[j]].(map[string]interface{})
|
||||
ri, _ := fi["required"].(bool)
|
||||
rj, _ := fj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
|
||||
for i := len(parts); i >= 1; i-- {
|
||||
candidateName := strings.Join(parts[:i], ".")
|
||||
if res, ok := resources[candidateName]; ok {
|
||||
if resMap, ok := res.(map[string]interface{}); ok {
|
||||
return resMap, candidateName, parts[i:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
// Args are the positional path segments, in either the dotted single-arg
|
||||
// form ("im.messages.reply") or the space-separated form ("im messages
|
||||
// reply"); apicatalog.ParsePath normalizes both.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
|
||||
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Args = append([]string(nil), args...)
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// Tolerated for agent compatibility; ignored — schema only emits the JSON
|
||||
// envelope, and its output is identity-independent (strict-mode filtering
|
||||
// comes from ResolveStrictMode, never from --as).
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().Bool("json", true, "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
_ = cmd.Flags().MarkHidden("format")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return completions
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
}
|
||||
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
|
||||
if target.Kind == apicatalog.TargetMethod {
|
||||
if len(refs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"Method %s not available in current identity mode", target.Method.SchemaPath()).
|
||||
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
output.PrintJson(out, schema.Envelopes(refs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
|
||||
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
|
||||
// preserving the historical message + hint text.
|
||||
func resolveError(err error) error {
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
switch re.Kind {
|
||||
case apicatalog.ErrService:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrResource:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrMethod:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrPath:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
|
||||
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -21,29 +22,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
|
||||
cmd.SetArgs([]string{"calendar.events.list"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Path != "calendar.events.list" {
|
||||
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
|
||||
}
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
|
||||
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
||||
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
||||
// Agents are habituated to --format/--json/--as from api/service commands.
|
||||
// schema must accept them without erroring and always emit the JSON envelope —
|
||||
// its output is structured JSON and identity-independent, so the values have
|
||||
// no effect.
|
||||
argSets := [][]string{
|
||||
{"--format", "json"},
|
||||
{"--format", "pretty"},
|
||||
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
||||
{"--format", "csv"},
|
||||
{"--json"},
|
||||
{"--json", "--format", "ndjson"},
|
||||
{"--as", "user"},
|
||||
{"--as", "bot"},
|
||||
{"--as", "user", "--json"},
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list in pretty mode")
|
||||
for _, extra := range argSets {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +69,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -76,7 +94,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
cmd.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -179,23 +197,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -210,170 +211,47 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "Unknown service") {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
if !strings.Contains(ve.Hint, "Available:") {
|
||||
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
|
||||
// JSON-mode unknown-method path: *errs.ValidationError with
|
||||
// subtype invalid_argument and a hint listing the available methods.
|
||||
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown method")
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
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 = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unknown method") {
|
||||
t.Errorf("expected 'Unknown method' error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "Available:") {
|
||||
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
// Completion candidate generation (dotted + space forms, strict-mode filtering,
|
||||
// dotted-resource handling) now lives in internal/apicatalog and is covered by
|
||||
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
|
||||
|
||||
256
cmd/service/affordance.go
Normal file
256
cmd/service/affordance.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
||||
// skill pointer) to a top-level Lark domain's description, returning false for
|
||||
// anything that is not such a domain. Built lazily at help time because
|
||||
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
||||
// skill pointer.
|
||||
//
|
||||
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
||||
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
||||
// we fall back to it. The pristine base is captured once into an annotation so
|
||||
// re-rendering does not append the guidance twice.
|
||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||
return false // a method command
|
||||
}
|
||||
// Direct child of root only — so Domain() reads this command's own tag, and
|
||||
// nested resource groups are excluded.
|
||||
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
||||
return false
|
||||
}
|
||||
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
||||
return false
|
||||
}
|
||||
if !cmd.HasAvailableSubCommands() {
|
||||
return false
|
||||
}
|
||||
|
||||
hasShortcuts, hasResources := false, false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(c.Name(), "+") {
|
||||
hasShortcuts = true
|
||||
} else {
|
||||
hasResources = true
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(domainHelpBase(cmd))
|
||||
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||
}
|
||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
if base == "" {
|
||||
base = cmd.Short
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
return base
|
||||
}
|
||||
|
||||
// methodLong is the build-time Long (description + schema pointer +
|
||||
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
||||
// so command construction never parses the overlay.
|
||||
func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
}
|
||||
schemaPath, ok := ann[schemaPathAnnotation]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
||||
// it carries none.
|
||||
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordance(meta.Method{Affordance: raw})
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block, or "" when it
|
||||
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
if strings.TrimSpace(it) != "" {
|
||||
nonEmpty = append(nonEmpty, it)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
var s strings.Builder
|
||||
fmt.Fprintf(&s, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&s, " • %s\n", it)
|
||||
}
|
||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.AvoidWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
bullets("Tips", a.Tips)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
if ex.Command == "" {
|
||||
continue
|
||||
}
|
||||
if ex.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
for _, ext := range a.Extensions {
|
||||
bullets(ext.Label, ext.Items)
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
185
cmd/service/affordance_test.go
Normal file
185
cmd/service/affordance_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"avoid_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"tips": ["富文本用 msg_type=post"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
{"description":"no command, skipped","command":""}
|
||||
],
|
||||
"related": ["im.messages.list"]
|
||||
}`)
|
||||
out := renderAffordance(meta.Method{Affordance: raw})
|
||||
for _, want := range []string{
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Tips:", "富文本用 msg_type=post",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "no command, skipped") {
|
||||
t.Errorf("example with empty command should be skipped:\n%s", out)
|
||||
}
|
||||
|
||||
// Absent or empty affordance renders nothing (so methods without an overlay
|
||||
// add nothing to their help).
|
||||
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
|
||||
t.Error("empty affordance should render nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Affordance is rendered lazily (at --help time) rather than baked into the
|
||||
// command's Long, so building a command never carries the affordance block —
|
||||
// even for a method whose metadata happens to declare one.
|
||||
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if strings.Contains(cmd.Long, "Examples:") {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
||||
// lookup and renders it; commands without a ref render nothing.
|
||||
func TestRenderAffordanceForCmd(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service != "im" || methodID != "messages.create" {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
||||
block := RenderAffordanceForCmd(cmd)
|
||||
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
|
||||
// No overlay for this method id -> empty block.
|
||||
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
||||
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
||||
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
||||
// then the affordance block, then the full-schema pointer — so an agent reads
|
||||
// when-to-use/examples before the flag list.
|
||||
func TestPrepareMethodHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
// Description leads; affordance block sits above the schema pointer.
|
||||
descAt := strings.Index(long, "发送消息")
|
||||
useAt := strings.Index(long, "When to use:")
|
||||
exAt := strings.Index(long, "Examples:")
|
||||
schemaAt := strings.Index(long, "Full parameter schema:")
|
||||
if descAt != 0 {
|
||||
t.Errorf("description should lead Long, got:\n%s", long)
|
||||
}
|
||||
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
||||
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
||||
cmdmeta.SetDomain(dom, "event")
|
||||
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
return dom
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
||||
dom := domainCmd("Consume and manage real-time events", long)
|
||||
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, long) {
|
||||
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
||||
}
|
||||
if !strings.Contains(dom.Long, "Risk levels") {
|
||||
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
||||
}
|
||||
|
||||
// Re-rendering must not append the guidance a second time.
|
||||
PrepareDomainHelp(dom, nil)
|
||||
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
||||
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
211
cmd/service/flaggroups.go
Normal file
211
cmd/service/flaggroups.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Flag annotations the grouped service-method help renderer reads.
|
||||
const (
|
||||
flagGroupAnnotation = "lark_flag_group" // display group key
|
||||
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
|
||||
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
|
||||
|
||||
groupParams = "params" // typed path/query flags
|
||||
groupBody = "body" // --data, --file
|
||||
groupRaw = "raw" // --params
|
||||
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
|
||||
groupOutput = "output" // --output/--format/--jq
|
||||
|
||||
subRequired = "required"
|
||||
subOptional = "optional"
|
||||
)
|
||||
|
||||
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
|
||||
// Parameters carries only typed path/query flags; raw --params, request body and
|
||||
// execution/output controls each get their own group so an agent can tell the
|
||||
// distinct input kinds apart.
|
||||
var serviceFlagGroupOrder = []struct{ key, title string }{
|
||||
{groupParams, "API Parameters"},
|
||||
{groupBody, "Request Body"},
|
||||
{groupRaw, "Raw Parameter Input"},
|
||||
{groupExecution, "Execution"},
|
||||
{groupOutput, "Output"},
|
||||
}
|
||||
|
||||
// applyGroupedUsage installs the grouped usage renderer on a service method
|
||||
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
|
||||
// list; global (inherited) flags and the Risk/Tips sections appended by the
|
||||
// root help func are unaffected. Rendered by hand rather than via
|
||||
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
|
||||
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
|
||||
// that disables the linker's method-level dead-code elimination and costs
|
||||
// ~19 MB of binary size.
|
||||
func applyGroupedUsage(cmd *cobra.Command) {
|
||||
cmd.SetUsageFunc(func(c *cobra.Command) error {
|
||||
w := c.OutOrStderr()
|
||||
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
|
||||
if c.HasAvailableLocalFlags() {
|
||||
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
|
||||
}
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func annotate(f *pflag.Flag, key string, vals []string) {
|
||||
if f.Annotations == nil {
|
||||
f.Annotations = map[string][]string{}
|
||||
}
|
||||
f.Annotations[key] = vals
|
||||
}
|
||||
|
||||
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
|
||||
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
|
||||
if f := fs.Lookup(name); f != nil {
|
||||
annotate(f, flagGroupAnnotation, []string{group})
|
||||
}
|
||||
}
|
||||
|
||||
func annotationOf(f *pflag.Flag, key string) []string {
|
||||
if f.Annotations != nil {
|
||||
return f.Annotations[key]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagGroupOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flagSubOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderServiceFlagGroups renders the command's local flags into ordered,
|
||||
// titled groups; the API Parameters group is further split into Required /
|
||||
// Optional. It is the body of the usage func applyGroupedUsage installs.
|
||||
func renderServiceFlagGroups(cmd *cobra.Command) string {
|
||||
var b strings.Builder
|
||||
seen := map[*pflag.Flag]bool{}
|
||||
for _, g := range serviceFlagGroupOrder {
|
||||
flags := groupFlags(cmd, g.key, seen)
|
||||
if len(flags) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", g.title)
|
||||
if g.key == groupParams {
|
||||
writeSection(&b, " Required:", subFlags(flags, subRequired))
|
||||
writeSection(&b, " Optional:", subFlags(flags, subOptional))
|
||||
} else {
|
||||
writeSection(&b, "", flags)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
// Anything untagged (e.g. -h/--help) goes last under "Other".
|
||||
var other []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || seen[f] {
|
||||
return
|
||||
}
|
||||
other = append(other, f)
|
||||
})
|
||||
if len(other) > 0 {
|
||||
fmt.Fprintln(&b, "Other:")
|
||||
writeSection(&b, "", other)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// groupFlags returns the visible local flags tagged with group key, marking them
|
||||
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
|
||||
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
|
||||
var flags []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || flagGroupOf(f) != key {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
seen[f] = true
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
|
||||
var out []*pflag.Flag
|
||||
for _, f := range flags {
|
||||
s := flagSubOf(f)
|
||||
// Untagged subgroup defaults to Optional so nothing is dropped.
|
||||
if s == sub || (s == "" && sub == subOptional) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSection prints an optional (sub)header and the flags, aligned in a
|
||||
// column, each flag row followed by its note lines indented under the usage.
|
||||
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
if header != "" {
|
||||
fmt.Fprintf(b, "%s\n", header)
|
||||
}
|
||||
specs := make([]string, len(flags))
|
||||
maxSpec := 0
|
||||
for i, f := range flags {
|
||||
specs[i] = flagSpec(f)
|
||||
if len(specs[i]) > maxSpec {
|
||||
maxSpec = len(specs[i])
|
||||
}
|
||||
}
|
||||
for i, f := range flags {
|
||||
_, usage := pflag.UnquoteUsage(f)
|
||||
if showsDefault(f) {
|
||||
usage += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
|
||||
for _, note := range annotationOf(f, flagNoteAnnotation) {
|
||||
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
|
||||
func flagSpec(f *pflag.Flag) string {
|
||||
typeName, _ := pflag.UnquoteUsage(f)
|
||||
spec := " --" + f.Name
|
||||
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
|
||||
spec = " -" + f.Shorthand + ", --" + f.Name
|
||||
}
|
||||
if typeName != "" {
|
||||
spec += " " + typeName
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
|
||||
// commands use, so the grouped rendering shows the same "(default x)" hints as
|
||||
// cobra's flat list.
|
||||
func showsDefault(f *pflag.Flag) bool {
|
||||
switch f.DefValue {
|
||||
case "", "0", "false", "[]":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
118
cmd/service/flaggroups_test.go
Normal file
118
cmd/service/flaggroups_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "chats/:chat_id/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
|
||||
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Documented body field -> --data belongs under Request Body.
|
||||
"requestBody": map[string]interface{}{
|
||||
"id_list": map[string]interface{}{"type": "list", "required": true},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
idx := func(s string) int { return strings.Index(out, s) }
|
||||
|
||||
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
|
||||
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
|
||||
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
|
||||
if i < 0 {
|
||||
t.Fatalf("missing section %q in:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
|
||||
t.Errorf("section order wrong:\n%s", out)
|
||||
}
|
||||
|
||||
// Required/Optional subsections under API Parameters.
|
||||
if i := idx(" Required:"); i < iParams || i > iBody {
|
||||
t.Errorf("Required subsection misplaced:\n%s", out)
|
||||
}
|
||||
if i := idx(" Optional:"); i < iParams || i > iBody {
|
||||
t.Errorf("Optional subsection misplaced:\n%s", out)
|
||||
}
|
||||
|
||||
// Typed flags are API Parameters; required path flag under Required, enum
|
||||
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
||||
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
||||
// key by the schema envelope — so it isn't echoed on every flag line.
|
||||
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
||||
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
}
|
||||
|
||||
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
|
||||
// and carries the precedence rule.
|
||||
if i := idx("--data"); i < iBody || i > iRaw {
|
||||
t.Errorf("--data not under Request Body:\n%s", out)
|
||||
}
|
||||
if i := idx("--params"); i < iRaw || i > iExec {
|
||||
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "typed flags override matching keys in --params") {
|
||||
t.Errorf("missing --params precedence rule:\n%s", out)
|
||||
}
|
||||
|
||||
// Control flags land in Execution/Output.
|
||||
if i := idx("--dry-run"); i < iExec || i > iOut {
|
||||
t.Errorf("--dry-run not under Execution:\n%s", out)
|
||||
}
|
||||
if idx("--format") < iOut {
|
||||
t.Errorf("--format not under Output:\n%s", out)
|
||||
}
|
||||
|
||||
// The usage template is wired to the grouped renderer (no flat Flags: list).
|
||||
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
|
||||
t.Errorf("usage template not grouped:\n%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
|
||||
// fields still offers --data (escape hatch) but must NOT imply a declared body —
|
||||
// it goes under Raw Parameter Input, not "Request Body".
|
||||
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
|
||||
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
if strings.Contains(out, "Request Body:") {
|
||||
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
|
||||
}
|
||||
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
|
||||
if iRaw < 0 || iData < iRaw {
|
||||
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no documented fields") {
|
||||
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
166
cmd/service/paramflags.go
Normal file
166
cmd/service/paramflags.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type boundParamFlag struct {
|
||||
field meta.Field
|
||||
read func() interface{}
|
||||
}
|
||||
|
||||
// paramsOnlyField is a path/query parameter that got no typed flag because its
|
||||
// kebab name is already taken by another flag (a standard flag like --format, or
|
||||
// a root persistent flag). It stays reachable via --params; the binder keeps it,
|
||||
// with the flag that claimed the name, so --help can show the exact --params form
|
||||
// and steer the reader off the wrong flag.
|
||||
type paramsOnlyField struct {
|
||||
field meta.Field
|
||||
claimed *pflag.Flag
|
||||
}
|
||||
|
||||
// paramFlagBinder owns one service method's generated typed param flags: it
|
||||
// registers them (kind, help, enum completion, reserved-name skip) and applies
|
||||
// the --params overlay, where a changed typed flag overrides its key in the
|
||||
// --params JSON. Holding the field<->flag binding here keeps the request builder
|
||||
// from re-deriving which flags map to which param keys.
|
||||
type paramFlagBinder struct {
|
||||
bound []boundParamFlag
|
||||
paramsOnly []paramsOnlyField
|
||||
}
|
||||
|
||||
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
|
||||
// cmd and returns a binder for the --params overlay. A name already taken by
|
||||
// another flag is skipped — pflag panics on a local duplicate and a generated
|
||||
// flag would silently shadow a persistent one — and recorded as paramsOnly so
|
||||
// the parameter stays reachable (and discoverable) via --params. The taken set
|
||||
// is derived, not hand-listed: local flags (the standard set, registered before
|
||||
// this runs) via cmd, the lazily-added --help materialized here, and the root's
|
||||
// persistent flags via reserved (nil for direct callers that have no root).
|
||||
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
|
||||
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
|
||||
b := ¶mFlagBinder{}
|
||||
for _, f := range params {
|
||||
name := f.FlagName()
|
||||
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
|
||||
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
|
||||
continue
|
||||
}
|
||||
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
|
||||
if values := enumStrings(f.EnumValues()); len(values) > 0 {
|
||||
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return values, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
// Group as an API parameter and mark required/optional for the
|
||||
// Required/Optional subsections of the grouped --help renderer.
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupParams})
|
||||
sub := subOptional
|
||||
if f.Required {
|
||||
sub = subRequired
|
||||
}
|
||||
annotate(fl, flagSubAnnotation, []string{sub})
|
||||
}
|
||||
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// flagClaiming returns the flag already occupying name (so a typed param flag
|
||||
// would collide), or nil when the name is free. It checks the command's own
|
||||
// flags (the standard set + the materialized --help) and the root's persistent
|
||||
// flags — so the reserved set is whatever is actually registered, never a
|
||||
// hand-kept list that drifts when a global flag is added.
|
||||
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
return fl
|
||||
}
|
||||
if reserved != nil {
|
||||
return reserved.Lookup(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
|
||||
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
|
||||
// the same fieldFacts a typed flag would show on its usage line, and what the
|
||||
// colliding flag actually does — so neither a human nor an agent sets the
|
||||
// wrong one (e.g. --format, which is the output format, not the API parameter).
|
||||
func (b *paramFlagBinder) paramsOnlyHelp() string {
|
||||
if len(b.paramsOnly) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
|
||||
for _, p := range b.paramsOnly {
|
||||
name := p.field.Name
|
||||
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
|
||||
for _, fact := range fieldFacts(p.field) {
|
||||
fmt.Fprintf(&sb, " %s\n", fact)
|
||||
}
|
||||
if p.claimed != nil {
|
||||
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// hasTypedFlag reports whether the binder registered a typed flag for the
|
||||
// param named name. False for params-only fields — a flag with the same kebab
|
||||
// name may exist (that's the collision), but it is not this param's input.
|
||||
// Nil-safe for direct buildServiceRequest callers that have no binder.
|
||||
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if pf.field.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// overlay lets an explicit typed flag override the same key in --params
|
||||
// (--params is the base). Only changed flags apply, so the --params-only path is
|
||||
// unchanged. A nil binder or cmd is a no-op.
|
||||
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
|
||||
if b == nil || cmd == nil {
|
||||
return
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if cmd.Flags().Changed(pf.field.FlagName()) {
|
||||
params[pf.field.Name] = pf.read()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
|
||||
// and returns a reader for its parsed value; the kind→pflag-type switch lives
|
||||
// only here.
|
||||
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
|
||||
switch kind {
|
||||
case "integer":
|
||||
return flagReader(fs.Int(name, 0, usage))
|
||||
case "boolean":
|
||||
return flagReader(fs.Bool(name, false, usage))
|
||||
case "array":
|
||||
return flagReader(fs.StringArray(name, nil, usage))
|
||||
default:
|
||||
return flagReader(fs.String(name, "", usage))
|
||||
}
|
||||
}
|
||||
|
||||
func flagReader[T any](p *T) func() interface{} {
|
||||
return func() interface{} { return *p }
|
||||
}
|
||||
626
cmd/service/paramflags_test.go
Normal file
626
cmd/service/paramflags_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
|
||||
// optional enum query param — the canonical case from the screenshot feedback.
|
||||
func imChatMembersCreate() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query", "required": false,
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id"},
|
||||
map[string]interface{}{"value": "user_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
|
||||
if cmd.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag for path param chat_id")
|
||||
}
|
||||
if cmd.Flags().Lookup("member-id-type") == nil {
|
||||
t.Error("expected generated --member-id-type flag for query param member_id_type")
|
||||
}
|
||||
}
|
||||
|
||||
// A query param literally named "format" kebab-collides with the global
|
||||
// --format flag. Generation must skip it (never re-register, never panic) and
|
||||
// leave the standard --format flag intact.
|
||||
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
}
|
||||
|
||||
var cmd *cobra.Command
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
|
||||
}
|
||||
}()
|
||||
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
}()
|
||||
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
|
||||
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --params is the base; an explicit typed flag overrides the same key.
|
||||
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "chats/oc_flag/members") {
|
||||
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "oc_params") {
|
||||
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Override works for a non-string (integer) param too, exercising the int
|
||||
// register/read path end to end.
|
||||
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
|
||||
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no typed flags passed, behavior is byte-identical to today.
|
||||
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
|
||||
// flag overlaying onto it must not panic (assignment to a nil map) — null is
|
||||
// treated as "no base params", with the flag value applied on top.
|
||||
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Startup smoke test: registering every embedded method must not panic on a
|
||||
// generated-flag name collision (pflag panics on duplicate registration, which
|
||||
// would crash the whole CLI at startup), and a known path param must surface as
|
||||
// a typed flag end to end.
|
||||
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("registering all service commands panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
RegisterServiceCommands(root, f)
|
||||
|
||||
create, _, err := root.Find([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("im chat.members create not registered: %v", err)
|
||||
}
|
||||
if create.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag on im chat.members create")
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the boolean and array branches of bindParamFlag end to end (string and
|
||||
// integer are covered above): a bool flag yields true and a repeatable array
|
||||
// flag yields all its elements in the request.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override (--params base, typed flag wins) is covered for string and integer
|
||||
// above; this locks the same semantics for the boolean and array kinds.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
|
||||
"--with-deleted", "--ids", "a", "--ids", "b",
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "from_params") {
|
||||
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A param whose kebab name collides with a global flag (here "format" vs the
|
||||
// global --format) gets no typed flag, but the collision is no longer silent:
|
||||
// non-colliding params still get flags, the global --format is untouched, and
|
||||
// --help shows the exact --params form and steers the reader off --format.
|
||||
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "things/{thing_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
|
||||
map[string]interface{}{"value": "full"},
|
||||
map[string]interface{}{"value": "metadata"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
|
||||
|
||||
if cmd.Flags().Lookup("thing-id") == nil {
|
||||
t.Error("non-colliding param should still get a typed --thing-id flag")
|
||||
}
|
||||
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
|
||||
}
|
||||
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The collision guard derives reserved names from the actual flag sets — local
|
||||
// flags plus the root's persistent flags passed in — so a future persistent
|
||||
// flag is covered with no hand-maintained list. Here a param named "profile"
|
||||
// (a root persistent flag) is skipped while a normal param is bound.
|
||||
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
|
||||
reserved.String("profile", "", "use a specific profile")
|
||||
|
||||
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"profile": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"id": map[string]interface{}{"type": "string", "location": "path"},
|
||||
}})
|
||||
b := newParamFlagBinder(cmd, m.Params(), reserved)
|
||||
|
||||
if cmd.Flags().Lookup("id") == nil {
|
||||
t.Error("non-colliding param should get a typed flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("profile") != nil {
|
||||
t.Error("param colliding with a reserved persistent flag must not be registered")
|
||||
}
|
||||
found := false
|
||||
for _, p := range b.paramsOnly {
|
||||
if p.field.Name == "profile" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("colliding param should be recorded for the --params help note")
|
||||
}
|
||||
}
|
||||
|
||||
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
|
||||
// boolean and one integer query param, where false and 0 are meaningful values.
|
||||
func boolIntQueryMethod(required bool) meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
// user choice.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly provided false satisfies a required query parameter — the
|
||||
// pre-flight must not report "missing" for a value the user just set.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
|
||||
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The same presence-is-intent rule applies to the --params JSON base: a key
|
||||
// deliberately written as false/0 is sent. (Zero values used to be silently
|
||||
// dropped; this locks the corrected semantics as the contract.)
|
||||
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "" stays unusable: a required parameter fed an empty-string placeholder is
|
||||
// still caught by the friendly pre-flight error, not sent as an empty value.
|
||||
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
|
||||
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared optional query param fed "" is dropped (unusable value), not sent
|
||||
// as an empty query value — the declared-param loop owns the decision and the
|
||||
// undeclared passthrough must not resurrect it. Undeclared keys stay the
|
||||
// verbatim raw escape hatch.
|
||||
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "user_id_type") {
|
||||
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"custom_key": "v1"`) {
|
||||
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// min/max from the metadata surface on the typed flag's help line, in the same
|
||||
// vocabulary as the envelope's minimum/maximum.
|
||||
func TestParamFlagUsage_Bounds(t *testing.T) {
|
||||
cases := []struct{ name, min, max, want string }{
|
||||
{"both", "1", "100", "min: 1, max: 100"},
|
||||
{"min only", "1", "", "min: 1"},
|
||||
{"max only", "", "64", "max: 64"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
|
||||
t.Errorf("usage = %q, want contains %q", usage, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no bounds, no clause", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_token": map[string]interface{}{"type": "string", "location": "query"},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
|
||||
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The sanitized field description rides the help line — a bare name like
|
||||
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
|
||||
// at sentence ends (。): the later sentence often holds the key affordance.
|
||||
func TestParamFlagUsage_Description(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"user_mailbox_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
|
||||
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, "补充说明") {
|
||||
t.Errorf("text after the note separator must be cut, got %q", usage)
|
||||
}
|
||||
|
||||
t.Run("long description truncated", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": strings.Repeat("长", 80),
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, "...") {
|
||||
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, strings.Repeat("长", 61)) {
|
||||
t.Errorf("description should not exceed the cap, got %q", usage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query", "description": "返回格式。",
|
||||
},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
|
||||
t.Errorf("clause join must not double the punctuation, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pins the convergence contract: the params-only addendum renders the SAME
|
||||
// fieldFacts list the typed flag's usage line joins inline — a fact added to
|
||||
// fieldFacts reaches both surfaces, and neither can drift over what a param's
|
||||
// help says (the addendum once rendered values-only enums and silently lacked
|
||||
// the API default).
|
||||
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
|
||||
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": "模式选择。",
|
||||
"default": "fast",
|
||||
"min": "1", "max": "8",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "fast", "description": "快速"},
|
||||
map[string]interface{}{"value": "full"},
|
||||
},
|
||||
},
|
||||
}}).Params()[0]
|
||||
|
||||
facts := fieldFacts(f)
|
||||
if len(facts) != 4 { // description, enum, bounds, API default
|
||||
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
|
||||
}
|
||||
usage := paramFlagUsage(f)
|
||||
help := (¶mFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
|
||||
for _, fact := range facts {
|
||||
if !strings.Contains(usage, fact) {
|
||||
t.Errorf("usage line missing fact %q: %q", fact, usage)
|
||||
}
|
||||
if !strings.Contains(help, fact) {
|
||||
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds reach the registered flag's help end to end.
|
||||
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
|
||||
},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
|
||||
fl := cmd.Flags().Lookup("page-size")
|
||||
if fl == nil {
|
||||
t.Fatal("expected generated --page-size flag")
|
||||
}
|
||||
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
|
||||
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// The missing-required hint must name both recovery paths — the typed flag and
|
||||
// the --params fallback — so a reader who only knows one input style can
|
||||
// proceed without a round-trip through schema.
|
||||
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint %q should contain %q", ve.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A params-only required field (kebab name claimed by the standard --format
|
||||
// flag) has no typed flag to offer: the hint must give only the --params form,
|
||||
// never steer the reader to the colliding flag.
|
||||
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
|
||||
t.Errorf("hint %q should carry the --params form", ve.Hint)
|
||||
}
|
||||
if strings.Contains(ve.Hint, "set --format") {
|
||||
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
|
||||
}
|
||||
}
|
||||
177
cmd/service/paramhelp.go
Normal file
177
cmd/service/paramhelp.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Help rendering for generated param flags. fieldFacts is the single list of
|
||||
// agent-relevant facts a param exposes; every help surface (the typed flag's
|
||||
// usage line, the params-only --params addendum) renders that one list, so the
|
||||
// surfaces cannot drift over which facts exist. Values come from the
|
||||
// meta.Field accessors, so nothing here depends on internal/schema.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// fieldFacts returns a param field's facts in display order, each as a compact
|
||||
// one-line clause: the sanitized description, the allowed enum values (with
|
||||
// meanings), the min/max constraint, and the API default. This is the ONE
|
||||
// place that decides what a param's help says — add a fact here (e.g. a future
|
||||
// deprecation marker) and every surface shows it. Unabridged prose and
|
||||
// per-option detail stay in `lark-cli schema`.
|
||||
func fieldFacts(f meta.Field) []string {
|
||||
var facts []string
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if f.CanonicalType() == "boolean" {
|
||||
// cobra shows no type word for bools and swallows a separate value as a
|
||||
// positional, so spell out the presence-only contract.
|
||||
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
if b := formatBoundsInline(f); b != "" {
|
||||
facts = append(facts, b)
|
||||
}
|
||||
if s := literalStr(f.CoercedDefault()); s != "" {
|
||||
facts = append(facts, "API default: "+s)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
||||
// joined inline. Required/optional is not repeated here — the grouped help's
|
||||
// Required:/Optional: subheadings already partition the flags — and the
|
||||
// snake-case --params key is carried by the schema envelope (each param's
|
||||
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
||||
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
||||
// flag with its type).
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
return strings.Join(fieldFacts(f), ". ")
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
// its first allowed enum value, else its example, else a placeholder.
|
||||
func paramExample(f meta.Field) string {
|
||||
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
|
||||
return fmt.Sprintf("%q", vals[0])
|
||||
}
|
||||
if s := literalStr(f.CoercedExample()); s != "" {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return `"<value>"`
|
||||
}
|
||||
|
||||
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||
|
||||
// inlineClause compresses metadata prose into one help clause: markdown links
|
||||
// keep their text, the clause cuts at the first rune in stops, whitespace
|
||||
// collapses, trailing punctuation goes — sentence enders (the clause join adds
|
||||
// its own) and connectors a cut can strand, like a colon introducing a list the
|
||||
// newline cut dropped — and the result caps at max runes. The two policies
|
||||
// below differ only in where they cut and how much they keep.
|
||||
func inlineClause(s, stops string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = markdownLinkRe.ReplaceAllString(s, "$1")
|
||||
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
|
||||
// flag's usage string as the flag's metavar, so a description like wiki
|
||||
// space_id's "可替换为`my_library`" would render the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
if i := strings.IndexAny(s, stops); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.TrimRight(s, "。.::,,、")
|
||||
return util.TruncateStrWithEllipsis(s, max)
|
||||
}
|
||||
|
||||
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
|
||||
// keep only the first clause (cut at 。 too) and stay ultra-compact.
|
||||
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
|
||||
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
||||
// cross-reference is dropped first (see cutDocRef).
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
||||
|
||||
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
||||
// On the compact flag line the markdown link's URL is stripped, so the
|
||||
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
||||
// so a subject that runs straight into the phrase isn't orphaned.
|
||||
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
||||
|
||||
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
||||
func cutDocRef(s string) string {
|
||||
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
||||
return s[:loc[0]]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
|
||||
// live in the envelope's enumDescriptions / `lark-cli schema`.
|
||||
func formatEnumInline(opts []meta.EnumOption) string {
|
||||
items := make([]string, len(opts))
|
||||
for i, o := range opts {
|
||||
if d := sanitizeOptionDesc(o.Description); d != "" {
|
||||
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
|
||||
} else {
|
||||
items[i] = fmt.Sprintf("%v", o.Value)
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "|")
|
||||
}
|
||||
|
||||
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
|
||||
// 100", or the single declared side), or "" when the field declares neither.
|
||||
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
|
||||
// schema` state the same constraint.
|
||||
func formatBoundsInline(f meta.Field) string {
|
||||
min, max := f.MinBound(), f.MaxBound()
|
||||
switch {
|
||||
case min != nil && max != nil:
|
||||
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
|
||||
case min != nil:
|
||||
return "min: " + formatBound(*min)
|
||||
case max != nil:
|
||||
return "max: " + formatBound(*max)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatBound renders a bound without a float artifact (100 not 100.000000).
|
||||
func formatBound(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// literalStr renders a coerced literal (default/example) for flag help,
|
||||
// returning "" for a nil or empty value so the caller can omit the clause.
|
||||
func literalStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func enumStrings(enum []interface{}) []string {
|
||||
out := make([]string, 0, len(enum))
|
||||
for _, e := range enum {
|
||||
out = append(out, fmt.Sprintf("%v", e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
61
cmd/service/sanitize_test.go
Normal file
61
cmd/service/sanitize_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeOptionDesc(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"以 open_id 标识用户": "以 open_id 标识用户",
|
||||
"中文。English second clause": "中文", // first clause only (。)
|
||||
"head;tail": "head", // first clause (;)
|
||||
"line one\nline two": "line one", // first clause (newline)
|
||||
" spaced out ": "spaced out", // whitespace collapsed
|
||||
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeOptionDesc(in); got != want {
|
||||
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
|
||||
// rune-safe (no split mid-character).
|
||||
long := strings.Repeat("文", 60)
|
||||
got := sanitizeOptionDesc(long)
|
||||
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
|
||||
// A clause cut can strand a connector (e.g. a colon introducing a list the
|
||||
// newline cut drops, as in im.reactions.list's message_id); the help line
|
||||
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
|
||||
cases := map[string]string{
|
||||
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
|
||||
"see the list below:\nitem": "see the list below",
|
||||
"逗号结尾,\n下一行": "逗号结尾",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
|
||||
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
|
||||
// the flag's metavar: wiki space_id's description rendered the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
|
||||
want := "知识空间id,如果查询我的文档库可替换为my_library"
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,25 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
@@ -30,85 +34,102 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
|
||||
// Drive the service list from the same navigation catalog the method walk
|
||||
// uses, so registration is catalog-sourced end to end. Kept as a per-service
|
||||
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
|
||||
// no methods still gets its bare command (WalkMethods yields one ref per
|
||||
// method, so empty services would vanish).
|
||||
for _, svc := range catalog.Services() {
|
||||
if svc.Name == "" || svc.ServicePath == "" {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, svc, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
|
||||
|
||||
// Build the service's subtree from the catalog's method walk
|
||||
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
|
||||
// is sourced from the same navigation Module as schema/scope rather than a
|
||||
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
refs := apicatalog.ServiceMethods(svc, nil)
|
||||
|
||||
// Collect each resource's verbs up front so resourceShort can summarize a
|
||||
// resource as its verb list from the first ensureChildCommand call.
|
||||
verbs := map[string][]string{}
|
||||
for _, ref := range refs {
|
||||
key := strings.Join(ref.ResourcePath, ".")
|
||||
verbs[key] = append(verbs[key], ref.Method.Name)
|
||||
}
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
for _, ref := range refs {
|
||||
resCmd := svcCmd
|
||||
var path []string
|
||||
for _, seg := range ref.ResourcePath {
|
||||
path = append(path, seg)
|
||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// resourceShort summarizes a resource as its sorted verb list, or the
|
||||
// "<name> operations" placeholder for an intermediate group with no methods.
|
||||
func resourceShort(seg string, verbs []string) string {
|
||||
if len(verbs) == 0 {
|
||||
return seg + " operations"
|
||||
}
|
||||
sorted := append([]string(nil), verbs...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ", ")
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
|
||||
return d
|
||||
}
|
||||
return svc.Description
|
||||
}
|
||||
|
||||
// ensureChildCommand returns the child of parent named name, creating it (with
|
||||
// short) when absent — so re-registration merges into an existing command tree
|
||||
// instead of duplicating a level.
|
||||
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
svc = c
|
||||
break
|
||||
if c.Name() == name {
|
||||
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
|
||||
return c
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
cmd := &cobra.Command{Use: name, Short: short}
|
||||
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
|
||||
parent.AddCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
|
||||
type ServiceMethodOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
Spec map[string]interface{}
|
||||
Method map[string]interface{}
|
||||
SchemaPath string
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
ServicePath string
|
||||
Method meta.Method
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
@@ -123,41 +144,126 @@ type ServiceMethodOptions struct {
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
|
||||
// binder owns the generated typed param flags — registration and the
|
||||
// --params overlay — replacing the raw paramFlags side-channel.
|
||||
binder *paramFlagBinder
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
// detectFileFields returns the request-body file-upload field names.
|
||||
func detectFileFields(m meta.Method) []string {
|
||||
files := m.Files()
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
// NewCmdServiceMethodWithContext builds the command for one service method from
|
||||
// its (service, resource, method) coordinates, deriving the methodCommandSpec
|
||||
// via an apicatalog.MethodRef so direct callers and the catalog-driven
|
||||
// registration assemble the command identically.
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
m.Name = name
|
||||
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
|
||||
// No root in scope here; persistent-flag collisions don't apply to a
|
||||
// standalone command, and local/standard-flag collisions are still caught.
|
||||
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
|
||||
}
|
||||
|
||||
// methodCommandSpec is the static description of one generated service method
|
||||
// command, read off an apicatalog.MethodRef — the single place command
|
||||
// construction gets the method's facts (schema path, HTTP base path, risk,
|
||||
// identities, params, file fields, request-body support), so the cobra command
|
||||
// is assembled from a typed spec rather than recomputing paths/flags inline.
|
||||
type methodCommandSpec struct {
|
||||
method meta.Method
|
||||
schemaPath string // "service.resource.method", for the --help hint
|
||||
servicePath string // service HTTP base path
|
||||
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
|
||||
restricts bool // method declares accessTokens (identity-restricted)
|
||||
identities []string // permitted --as values; empty when unrestricted
|
||||
params []meta.Field // path/query params -> typed flags
|
||||
fileFields []string // request-body file-upload field names
|
||||
// acceptsBody is whether the HTTP method allows a request body at all (so
|
||||
// --data is offered as a raw escape hatch). declaresBody is whether the
|
||||
// metadata documents body fields (data or file). They differ for e.g. a POST
|
||||
// with no documented requestBody: --data still works, but help must not imply
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
||||
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
||||
}
|
||||
|
||||
// methodPaginates reports whether a method takes a page_token param, the signal
|
||||
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
||||
func methodPaginates(m meta.Method) bool {
|
||||
for _, f := range m.Params() {
|
||||
if f.Name == "page_token" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
m := ref.Method
|
||||
return methodCommandSpec{
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
serviceName: ref.Service.Name,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
params: m.Params(),
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
paginates: methodPaginates(m),
|
||||
}
|
||||
}
|
||||
|
||||
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
|
||||
// whether --data applies (as a raw escape hatch even when no body is declared).
|
||||
func methodTakesBody(httpMethod string) bool {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMethodCommand assembles the cobra command for a service method from its
|
||||
// static spec: the standard flags, the conditional --data/--file/--yes flags,
|
||||
// the generated typed param flags (via paramFlagBinder), and the risk/identity
|
||||
// policy annotations.
|
||||
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
|
||||
m := spec.method
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
Factory: f,
|
||||
ServicePath: spec.servicePath,
|
||||
Method: m,
|
||||
SchemaPath: spec.schemaPath,
|
||||
FileFields: spec.fileFields,
|
||||
}
|
||||
var asStr string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
Use: m.Name,
|
||||
Short: m.Description,
|
||||
// Long is assembled below, once the binder knows which params got no
|
||||
// typed flag.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
@@ -168,41 +274,89 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
return serviceMethodRun(opts)
|
||||
},
|
||||
}
|
||||
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
|
||||
if spec.acceptsBody {
|
||||
dataUsage := "JSON request body. Supports - and @file."
|
||||
if !spec.declaresBody {
|
||||
// POST/etc. with no documented body fields: --data is a raw escape
|
||||
// hatch, not a declared body — say so rather than imply structure.
|
||||
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
||||
// them from help on non-paginating commands, so help doesn't imply a
|
||||
// get/write can paginate.
|
||||
if !spec.paginates {
|
||||
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
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")
|
||||
if risk == "high-risk-write" {
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
// --file only for body methods that actually declare file-type fields.
|
||||
if len(spec.fileFields) > 0 && spec.acceptsBody {
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
||||
// (setMethodHelpData records the coordinates it needs).
|
||||
paramsOnly := opts.binder.paramsOnlyHelp()
|
||||
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
||||
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
// registered above (e.g. --data/--file/--yes only exist for some methods).
|
||||
// --data sits under Request Body only when the metadata documents body
|
||||
// fields; otherwise it's a raw escape hatch, grouped with --params so help
|
||||
// doesn't imply a declared body the API doesn't have.
|
||||
if fl := cmd.Flags().Lookup("data"); fl != nil {
|
||||
if spec.declaresBody {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupBody})
|
||||
} else {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
}
|
||||
}
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// Keep the precedence rule on the flag's own one line (not a multi-line
|
||||
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
||||
// meaningful when typed flags exist to override.
|
||||
if len(spec.params) > 0 {
|
||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupExecution)
|
||||
}
|
||||
for _, name := range []string{"output", "format", "jq"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupOutput)
|
||||
}
|
||||
applyGroupedUsage(cmd)
|
||||
|
||||
cmdutil.SetTips(cmd, m.Tips)
|
||||
cmdutil.SetRisk(cmd, spec.risk)
|
||||
if spec.restricts {
|
||||
cmdutil.SetSupportedIdentities(cmd, spec.identities)
|
||||
}
|
||||
|
||||
return cmd
|
||||
@@ -217,8 +371,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
|
||||
if opts.Method.RestrictsIdentity() {
|
||||
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -234,12 +388,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
// Identity is not printed to stderr here: it is part of the JSON envelope.
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -256,7 +408,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
@@ -279,7 +431,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := ac.CheckResponse
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -301,7 +453,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -310,23 +462,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
if len(method.RequiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(scopes) == 0 {
|
||||
if len(method.Scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -335,12 +479,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
for _, s := range method.Scopes {
|
||||
if grantedSet[s] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||
}
|
||||
|
||||
@@ -361,14 +505,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
|
||||
WithIdentity(identity)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
// cannot form a usable request value: nil or an empty string. A key's presence
|
||||
// in params is the intent signal — a typed flag is overlaid only when
|
||||
// explicitly Changed, and a --params JSON key is deliberately written — so
|
||||
// false and 0 are real values and must not be conflated with "unset"
|
||||
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
|
||||
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
|
||||
// error when a required param is fed an empty placeholder, and never emits a
|
||||
// declared param as an empty path segment or query value. Undeclared keys are
|
||||
// not judged by this rule — they pass through verbatim as the raw escape hatch.
|
||||
func unusableParamValue(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return ok && s == ""
|
||||
}
|
||||
|
||||
// missingParamHint is the recovery hint for a missing required parameter. It
|
||||
// names both input paths — the typed flag when the binder registered one, and
|
||||
// the --params fallback — plus the schema pointer. A params-only field gets
|
||||
// only the --params form: a flag with its kebab name exists but belongs to
|
||||
// something else (e.g. the output --format), and the hint must not steer
|
||||
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
httpMethod := method.HTTPMethod
|
||||
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
@@ -386,53 +560,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
opts.binder.overlay(opts.Cmd, params)
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
url := opts.ServicePath + "/" + method.Path
|
||||
|
||||
parameters, _ := method["parameters"].(map[string]interface{})
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "path" {
|
||||
specs := method.Params()
|
||||
for _, s := range specs {
|
||||
if s.Location != "path" {
|
||||
continue
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, s.Name)
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{}
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "query" {
|
||||
for _, s := range specs {
|
||||
if s.Location != "query" {
|
||||
continue
|
||||
}
|
||||
value, exists := params[name]
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
value, exists := params[s.Name]
|
||||
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
|
||||
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
if exists && !util.IsEmptyValue(value) {
|
||||
queryParams[name] = value
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
}
|
||||
// This loop owns declared query params: consume the key so the
|
||||
// passthrough below can't resurrect a value the gate dropped (an
|
||||
// unusable "" would otherwise be sent as an empty query value).
|
||||
delete(params, s.Name)
|
||||
}
|
||||
// Whatever remains is undeclared — the raw escape hatch for params the
|
||||
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
|
||||
for name, value := range params {
|
||||
if _, ok := queryParams[name]; !ok {
|
||||
queryParams[name] = value
|
||||
}
|
||||
queryParams[name] = value
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
@@ -495,20 +671,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
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.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -518,7 +719,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
@@ -527,9 +733,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
// parameter and risk metadata. The test exercises --yes registration and the
|
||||
// gate behavior.
|
||||
func highRiskDeleteMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func writeMethodNoRisk() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user