mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
353 Commits
docs/block
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbaa7ff21 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
a72297b026 | ||
|
|
7512f017d6 | ||
|
|
87be09ef5f | ||
|
|
d0b26821df | ||
|
|
a575a8ba60 | ||
|
|
7faf9da3a4 | ||
|
|
ae56d30ce3 | ||
|
|
7e34eccf3a | ||
|
|
4c22015464 | ||
|
|
1698ac1ff3 | ||
|
|
8f3e9630a1 | ||
|
|
aa93b3f3a6 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
0885ec2eae | ||
|
|
b39258c169 | ||
|
|
2a252d2a80 | ||
|
|
1ba4d1fd77 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
bb7342c3cc | ||
|
|
f25ef0ae75 | ||
|
|
1b173e1953 | ||
|
|
5bae5bbbc2 | ||
|
|
57db1b3a8d | ||
|
|
d7cf797bdd | ||
|
|
0266a7e9a1 | ||
|
|
939fc1eeb8 | ||
|
|
4c1c5f5287 | ||
|
|
c69a61c672 | ||
|
|
ee27d0cdc2 | ||
|
|
3732c6bcce | ||
|
|
9d8e93c682 | ||
|
|
38312d3a9c | ||
|
|
342d1a247d | ||
|
|
770c23035c | ||
|
|
2634092ff2 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
9c50045f14 | ||
|
|
7b6962a726 | ||
|
|
1af34e6649 | ||
|
|
7abcaa7f68 | ||
|
|
c12ab91349 | ||
|
|
57bf8ccd1e | ||
|
|
97e397cf0c | ||
|
|
8fb2476985 | ||
|
|
c138f29972 | ||
|
|
2662729cd6 | ||
|
|
475f04a8dd | ||
|
|
52a1187c20 | ||
|
|
bf56e903ba | ||
|
|
b028c33e8f | ||
|
|
4073e75def | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
72ea02875c | ||
|
|
aec9c4677d | ||
|
|
38e8806d91 | ||
|
|
e5b2e96df4 | ||
|
|
602f6719dc | ||
|
|
85490cb3da | ||
|
|
2d1341aff6 | ||
|
|
aa50bec07e | ||
|
|
5157c3a00e | ||
|
|
5e23bbeddb | ||
|
|
d62f8dcbe8 | ||
|
|
8ba2431192 | ||
|
|
217f4e5567 | ||
|
|
820305536c | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
d48c218d0d | ||
|
|
abe0d09d4b | ||
|
|
e7d5ecdd01 | ||
|
|
7c2ca4e465 | ||
|
|
cbe0fb12df | ||
|
|
59b6393250 | ||
|
|
f1ce88b48e | ||
|
|
c09b0d5dd3 | ||
|
|
1772afe22d | ||
|
|
acd50f25fa | ||
|
|
4807283368 | ||
|
|
e488cf4cd3 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 | ||
|
|
d4168ab84f | ||
|
|
12ca42c953 | ||
|
|
d382ee9053 | ||
|
|
daaacb4977 | ||
|
|
680501c1df | ||
|
|
6675e3c247 | ||
|
|
7b48709438 | ||
|
|
c876841106 | ||
|
|
4c1a92caa6 | ||
|
|
577ff035c3 | ||
|
|
4b4ca4283a | ||
|
|
ad4a6d68c7 | ||
|
|
d8fb368ce4 | ||
|
|
40840915c7 | ||
|
|
fb57e17905 | ||
|
|
4cdfa2fcda | ||
|
|
3c2cc273f7 | ||
|
|
b52677269e | ||
|
|
78390f8ea1 | ||
|
|
d6cebd6723 | ||
|
|
79adf89beb | ||
|
|
9dd355a52d | ||
|
|
7b989948c4 | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd | ||
|
|
44514ad114 | ||
|
|
4a56748bfa | ||
|
|
0b6faa01bf | ||
|
|
1efe2dfb33 | ||
|
|
767386cb57 | ||
|
|
e71c76155e | ||
|
|
c363acf94e | ||
|
|
05285bb696 | ||
|
|
4c0f93bd6a | ||
|
|
76ebd49382 | ||
|
|
6c14c425fc | ||
|
|
27df16d3b2 | ||
|
|
47dc003601 | ||
|
|
4e0a6a988c | ||
|
|
708196040a | ||
|
|
65586577a3 | ||
|
|
be1f3621de | ||
|
|
65998a21e3 | ||
|
|
d5afe3f705 | ||
|
|
baf6050f8e | ||
|
|
a6bc81596a | ||
|
|
7f43b7ed5d | ||
|
|
80b3645362 | ||
|
|
64caef1526 | ||
|
|
64e10a0954 | ||
|
|
8897196dee | ||
|
|
49b4ccceb9 | ||
|
|
4b2d012af9 | ||
|
|
90aad64b8d | ||
|
|
2919084103 | ||
|
|
36bd82cb27 | ||
|
|
2e77d8db80 | ||
|
|
d9061ffcbc | ||
|
|
08d9b28ee8 | ||
|
|
168fb13e3e | ||
|
|
55c2e5c819 | ||
|
|
16a93cd277 | ||
|
|
e9dabb2184 | ||
|
|
8acd55e907 | ||
|
|
6ecbfaf690 | ||
|
|
ac2508d3b0 | ||
|
|
1c3674487f | ||
|
|
37d490a198 | ||
|
|
4e44e51bef | ||
|
|
e79d49e7e4 | ||
|
|
83352fe00b | ||
|
|
21bfa84edd | ||
|
|
fc8d212a4f | ||
|
|
35049e8d30 | ||
|
|
d8782e715a | ||
|
|
7675185f9d | ||
|
|
1ab853023a | ||
|
|
e96c4fa581 | ||
|
|
4847f06ca8 | ||
|
|
452734f824 | ||
|
|
0dd844c2c5 | ||
|
|
4a4cc1e0cf | ||
|
|
e967571829 | ||
|
|
b1205b68d2 | ||
|
|
519a600b62 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 | ||
|
|
cdd9d3409b | ||
|
|
06f6b0b18c | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
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 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
13
.github/workflows/arch-audit.yml
vendored
13
.github/workflows/arch-audit.yml
vendored
@@ -62,19 +62,6 @@ jobs:
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
|
||||
300
.github/workflows/ci.yml
vendored
300
.github/workflows/ci.yml
vendored
@@ -1,17 +1,23 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
@@ -48,6 +54,34 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -72,6 +106,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 +115,88 @@ 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: Enforce layering ratchet
|
||||
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . ..
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run source-contract lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
- name: Run lint module tests
|
||||
run: go test -C lint ./... -count=1
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
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
|
||||
@@ -100,9 +213,14 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
||||
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
go test -race -coverprofile=coverage.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,17 +302,45 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -202,18 +348,50 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -221,25 +399,75 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Build lark-cli
|
||||
id: build_cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
@@ -254,6 +482,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 +522,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, plugin-integration, sidecar-integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -303,21 +534,34 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Graduation to required is tracked in
|
||||
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
||||
# consecutive weeks with zero false positives).
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
|
||||
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"
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,7 +9,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,35 +59,79 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
605
.github/workflows/semantic-review.yml
vendored
Normal file
605
.github/workflows/semantic-review.yml
vendored
Normal file
@@ -0,0 +1,605 @@
|
||||
name: Semantic Review
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pr-quality-summary:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request for summary
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify summary facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract summary facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Publish PR quality summary
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/ci-quality-summary-publish.js");
|
||||
await publish({ github, context, core });
|
||||
|
||||
semantic-review:
|
||||
needs: pr-quality-summary
|
||||
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("head_owner", pr.head.repo.owner.login);
|
||||
core.setOutput("head_repo", pr.head.repo.name);
|
||||
core.setOutput("head_repo_id", String(pr.head.repo.id));
|
||||
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Publish pre-checkout semantic review failure
|
||||
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
|
||||
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
|
||||
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR base changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.repo.id !== context.payload.repository.id) {
|
||||
throw new Error("PR base repo mismatch before infrastructure failure check");
|
||||
}
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
|
||||
head_sha: headSha,
|
||||
status: "completed",
|
||||
conclusion: runtimeBlockMode ? "failure" : "neutral",
|
||||
output: {
|
||||
title: "Semantic review infrastructure failure",
|
||||
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
|
||||
},
|
||||
});
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify semantic facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
|
||||
throw new Error("missing verified facts artifact binding");
|
||||
}
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract semantic facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Download PR semantic waiver config
|
||||
id: waiver_config
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
|
||||
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
|
||||
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
|
||||
if (!headOwner || !headRepo) {
|
||||
throw new Error("missing verified semantic review head repository");
|
||||
}
|
||||
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
|
||||
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
|
||||
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
|
||||
if (!headIsBaseRepo) {
|
||||
core.notice("fork PR semantic waiver config is ignored");
|
||||
core.setOutput("path", "");
|
||||
return;
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: waiverPath,
|
||||
ref: headSha,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
|
||||
throw new Error(`${waiverPath} is not a base64 file at PR head`);
|
||||
}
|
||||
if (data.size > 256 * 1024) {
|
||||
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
|
||||
}
|
||||
content = Buffer.from(data.content, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outPath, content);
|
||||
core.setOutput("path", outPath);
|
||||
|
||||
- name: Run semantic review
|
||||
id: semantic
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
|
||||
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
|
||||
ARK_MODEL: ${{ vars.ARK_MODEL }}
|
||||
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
run: |
|
||||
args=(
|
||||
--repo .
|
||||
--facts facts.json
|
||||
--decision-out decision.json
|
||||
--markdown-out semantic-review.md
|
||||
)
|
||||
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
|
||||
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
|
||||
fi
|
||||
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
|
||||
args+=(--block)
|
||||
fi
|
||||
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
|
||||
|
||||
- name: Publish semantic review
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/semantic-review-publish.js");
|
||||
await publish({ github, context, core });
|
||||
9
.gitignore
vendored
9
.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/
|
||||
|
||||
|
||||
@@ -46,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,9 +49,16 @@ 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/
|
||||
@@ -65,31 +72,26 @@ linters:
|
||||
- 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/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
# 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
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -108,22 +110,6 @@ 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
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||
# common replacements or local typed helpers instead.
|
||||
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy or
|
||||
metadata-poor error shapes. Use typed common replacements, typed
|
||||
errs.NewXxxError builders, or domain-local typed helpers.
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
|
||||
35
AGENTS.md
35
AGENTS.md
@@ -10,9 +10,10 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
@@ -61,10 +62,21 @@ Both notices recommend the same fix command: `lark-cli update`. The skills notic
|
||||
| `internal/credential/` | Credential provider chain (extension → default) |
|
||||
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
|
||||
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
|
||||
| `internal/core/config.go` | Multi-profile config loading/saving |
|
||||
| `brand/` | Brand (feishu/lark) and its endpoint hosts — repo root, so `extension/` may import it |
|
||||
| `internal/workspace/` | Workspace detection plus the config and runtime directory paths |
|
||||
| `internal/identity/` | The `--as` identity (user/bot) and the strict-mode policy |
|
||||
| `internal/config/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
`internal/core` is gone. Besides the four packages above it also became
|
||||
`internal/secret` (app secret storage and resolution) and `internal/risk` (the
|
||||
read / write / high-risk-write vocabulary). Import the narrowest one you need:
|
||||
`brand`, `internal/workspace`, `internal/identity`, `internal/secret` and
|
||||
`internal/risk` do not import each other — only `internal/config` sits on top of
|
||||
them — so asking for a config directory no longer drags in keychain, i18n and
|
||||
validate.
|
||||
|
||||
## Who Uses This CLI
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
|
||||
@@ -105,6 +117,20 @@ Signatures that are easy to guess wrong:
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -116,6 +142,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
571
CHANGELOG.md
571
CHANGELOG.md
@@ -2,6 +2,553 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
|
||||
- add wiki move-to-drive shortcut (#1869)
|
||||
- **apps**: add role management shortcuts (#1881)
|
||||
- **drive**: add secure label support and clarify comment location API (#1913)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve dashboard shortcut guidance (#1787)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
|
||||
|
||||
### Misc
|
||||
|
||||
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
|
||||
- **drive**: optimize drive +delete workflow (#1909)
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
|
||||
- add minutes permission application shortcut (#1876)
|
||||
- **drive**: support apps in list comments (#1877)
|
||||
- slide style
|
||||
- edit ppt template
|
||||
- **slides**: add sxsd validation to slides lint
|
||||
- **slides**: validate iconpark icon types in slides lint
|
||||
- **slides**: lint before create
|
||||
- **apps**: add automation trigger commands for Miaoda (#1886)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- unify dry-run output contract (#1870)
|
||||
- **skills**: align skill guidance with the typed error contract (#1786)
|
||||
- **slides**: limit slides screenshot page requests
|
||||
- **slides**: detect lark slides text overflow overlap
|
||||
- **vc**: align meeting query scopes by identity (#1850)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify task search relevance filters (#1884)
|
||||
- surface minutes permission application in skill description (#1890)
|
||||
- clarify okr progress children (#1861)
|
||||
- **slides**: prefer slides xml-get shortcut
|
||||
- **calendar**: document setting meeting owner via full API (#1903)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **slides**: streamline create workflow and validate SML namespaces
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: address PR review feedback
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
|
||||
- **slides**: add slides chart demo reference
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- register and consume --json shorthand for custom-format shortcuts (#1737)
|
||||
- **drive**: abort push on parent sibling limit (#1813)
|
||||
|
||||
### Documentation
|
||||
|
||||
- require native charts in slide planning
|
||||
- register knowledge organize workflow (#1828)
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
@@ -1175,6 +1722,30 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
|
||||
60
Makefile
60
Makefile
@@ -5,6 +5,14 @@ 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
|
||||
|
||||
@@ -15,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -39,21 +47,61 @@ fmt-check:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/check-layering-ratchet.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
|
||||
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
|
||||
# Deliberate: local `make test` exercises the L4 plugin contract by default.
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet fmt-check 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
|
||||
@@ -66,6 +114,14 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
43
README.md
43
README.md
@@ -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.
|
||||
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # Comma-separated values
|
||||
```
|
||||
|
||||
### JSON Output Contract
|
||||
|
||||
With `--format json` (the default), success and error envelopes are distinct.
|
||||
|
||||
Success goes to **stdout**, exit code `0`:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
Errors go to **stderr**, non-zero exit code:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
@@ -267,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
43
README.zh.md
43
README.zh.md
@@ -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` 查看所有快捷命令。
|
||||
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # 逗号分隔值
|
||||
```
|
||||
|
||||
### JSON 输出契约
|
||||
|
||||
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||
|
||||
成功信封写入 **stdout**,退出码 0:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
错误信封写入 **stderr**,退出码非 0:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||
|
||||
### 分页
|
||||
|
||||
```bash
|
||||
@@ -268,6 +286,29 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
66
affordance/README.md
Normal file
66
affordance/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Affordance
|
||||
|
||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
|
||||
## Format
|
||||
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### Skills bullet skill names, or name/relpath references
|
||||
(lark-contact/references/x.md), to read for usage;
|
||||
merged with the domain `> skill:` default (deduped,
|
||||
domain first)
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||
replace the Go tips (not merged), so keep tips in one place.
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
Fetch the full content of a single message by id.
|
||||
|
||||
### Avoid when
|
||||
- Reading several at once → use [[messages batch_get]]
|
||||
|
||||
### Prerequisites
|
||||
- message_id from [[messages list]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Fetch one message**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||
90
affordance/contact.md
Normal file
90
affordance/contact.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## +search-user
|
||||
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-user.md
|
||||
|
||||
### Avoid when
|
||||
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Find a user by name**
|
||||
```bash
|
||||
lark-cli contact +search-user --query "alice" --as user
|
||||
```
|
||||
|
||||
**Fetch known users by open_id (me = yourself)**
|
||||
```bash
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-get-user.md
|
||||
|
||||
### Avoid when
|
||||
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||
|
||||
### Tips
|
||||
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||
- --user-id-type must match the id you pass (default open_id)
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
### Avoid when
|
||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||
|
||||
### Tips
|
||||
- Off by default — set include_personal_status / include_description to true under query_option
|
||||
- ids in user_ids must match --user-id-type (default open_id)
|
||||
|
||||
### Examples
|
||||
|
||||
**Bulk-query status and signature**
|
||||
```bash
|
||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||
```
|
||||
@@ -1,25 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
package brand
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
import "strings"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
type LarkBrand string
|
||||
// ParseBrand and ResolveEndpoints map unrecognized values to Feishu.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandFeishu LarkBrand = "feishu"
|
||||
BrandLark LarkBrand = "lark"
|
||||
Feishu Brand = "feishu"
|
||||
Lark Brand = "lark"
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
return BrandLark
|
||||
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
|
||||
// anything other than "lark" normalizes to Feishu.
|
||||
func ParseBrand(value string) Brand {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
|
||||
return Lark
|
||||
}
|
||||
return BrandFeishu
|
||||
return Feishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
@@ -36,10 +38,11 @@ type Endpoints struct {
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs based on brand.
|
||||
func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
switch brand {
|
||||
case BrandLark:
|
||||
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
|
||||
// input so stored values with unusual casing still resolve correctly.
|
||||
func ResolveEndpoints(brand Brand) Endpoints {
|
||||
switch ParseBrand(string(brand)) {
|
||||
case Lark:
|
||||
return Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
@@ -57,6 +60,6 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
}
|
||||
|
||||
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
func ResolveOpenBaseURL(brand Brand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
package brand
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
ep := ResolveEndpoints(Feishu)
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn", ep.Open)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveEndpoints_Lark(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandLark)
|
||||
ep := ResolveEndpoints(Lark)
|
||||
if ep.Open != "https://open.larksuite.com" {
|
||||
t.Errorf("Open = %q, want larksuite.com", ep.Open)
|
||||
}
|
||||
@@ -50,10 +50,44 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" {
|
||||
if got := ResolveOpenBaseURL(Feishu); got != "https://open.feishu.cn" {
|
||||
t.Errorf("ResolveOpenBaseURL(feishu) = %q", got)
|
||||
}
|
||||
if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" {
|
||||
if got := ResolveOpenBaseURL(Lark); got != "https://open.larksuite.com" {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Brand
|
||||
}{
|
||||
{"", Feishu},
|
||||
{"feishu", Feishu},
|
||||
{"lark", Lark},
|
||||
{"LARK", Lark},
|
||||
{" lark ", Lark},
|
||||
{"Lark", Lark},
|
||||
{"xyz", Feishu},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseBrand(c.in); got != c.want {
|
||||
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveEndpoints_NormalizesBrand locks the boundary invariant: the
|
||||
// resolver normalizes its brand input, so historical config values with
|
||||
// unusual casing or whitespace still resolve to their intended endpoints.
|
||||
func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
for _, raw := range []string{"LARK", " lark ", "Lark"} {
|
||||
if got := ResolveEndpoints(Brand(raw)).Open; got != "https://open.larksuite.com" {
|
||||
t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got)
|
||||
}
|
||||
}
|
||||
if got := ResolveEndpoints(Brand("unexpected")).Open; got != "https://open.feishu.cn" {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
136
cmd/api/api.go
136
cmd/api/api.go
@@ -10,9 +10,11 @@ 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"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -32,7 +34,7 @@ type APIOptions struct {
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As core.Identity
|
||||
As identity.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageSize int
|
||||
@@ -66,14 +68,27 @@ 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]
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
opts.As = identity.Identity(asStr)
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -116,6 +131,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
@@ -123,7 +145,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)
|
||||
@@ -153,7 +181,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +227,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
|
||||
@@ -214,9 +251,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
return apiDryRun(f, request, config, opts)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -233,7 +270,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})
|
||||
}
|
||||
|
||||
@@ -243,7 +280,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,
|
||||
@@ -263,55 +300,94 @@ 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
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
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 dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
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{}) {
|
||||
pf.FormatPage(items)
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
398
cmd/api/api_paginate_test.go
Normal file
398
cmd/api/api_paginate_test.go
Normal file
@@ -0,0 +1,398 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &configpkg.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: brand.Feishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: identity.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -130,7 +130,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, core.AsBot)
|
||||
}, identity.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory
|
||||
}
|
||||
raw["code"] = code
|
||||
raw["msg"] = msg
|
||||
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
||||
cc := errclass.ClassifyContext{Identity: string(identity.AsBot)}
|
||||
if cfg, _ := f.Config(); cfg != nil {
|
||||
cc.Brand = string(cfg.Brand)
|
||||
cc.AppID = appId
|
||||
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -23,8 +24,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *LoginOptions
|
||||
@@ -46,8 +47,8 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
|
||||
@@ -72,8 +73,8 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
@@ -92,8 +93,8 @@ 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,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
@@ -192,8 +193,8 @@ func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -211,8 +212,8 @@ 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,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -234,8 +235,8 @@ func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -336,8 +337,8 @@ func TestDomainFlagCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
@@ -356,8 +357,8 @@ 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,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
@@ -382,8 +383,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: brand.Feishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
@@ -438,8 +439,8 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
||||
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
||||
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -23,8 +24,8 @@ import (
|
||||
// branch. These tests pin that contract end-to-end through the dispatcher.
|
||||
|
||||
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
// UserOpenId left empty: triggers the not_logged_in branch.
|
||||
})
|
||||
|
||||
@@ -33,12 +34,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 {
|
||||
@@ -58,8 +56,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
UserOpenId: "ou_user", UserName: "tester",
|
||||
})
|
||||
|
||||
@@ -95,10 +93,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
cfg := &configpkg.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brand.Feishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
@@ -153,8 +151,8 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
// Scope validation is a real input error, not a predicate negative
|
||||
// answer — it must surface as a typed ValidationError with the normal
|
||||
// stderr envelope, distinct from the silent ErrBare predicate path.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
||||
|
||||
@@ -9,9 +9,10 @@ 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"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
func authListRun(opts *ListOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
@@ -59,8 +60,8 @@ func authListRun(opts *ListOptions) error {
|
||||
// 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
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
@@ -69,9 +69,9 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
prev := workspace.CurrentWorkspace()
|
||||
t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) })
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -55,7 +57,7 @@ send the verification URL (or QR code) to the user as your final message, end th
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == identity.StrictModeBot {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"strict mode is %q, user login is disabled in this profile", mode).
|
||||
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
@@ -72,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
var helpBrand brandpkg.Brand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
@@ -125,7 +127,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
var lang i18n.Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if multi, _ := configpkg.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
@@ -391,7 +393,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// authLoginPollDeviceCode resumes the device flow by polling with a device code
|
||||
// obtained from a previous --no-wait call.
|
||||
func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
|
||||
func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
|
||||
f := opts.Factory
|
||||
|
||||
httpClient, err := f.HttpClient()
|
||||
@@ -474,7 +476,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -484,9 +486,9 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
oldUsers := append([]configpkg.AppUser(nil), app.Users...)
|
||||
app.Users = []configpkg.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -499,7 +501,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *configpkg.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
@@ -512,7 +514,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
func collectScopesForDomains(domains []string, identity string, brand brandpkg.Brand) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -553,7 +555,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
func allKnownDomains(brand brandpkg.Brand) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -572,7 +574,7 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
func sortedKnownDomains(brand brandpkg.Brand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
|
||||
@@ -6,26 +6,26 @@ package auth
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/brand"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
feishuDomains := allKnownDomains(brand.Feishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
larkDomains := allKnownDomains(brand.Lark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Feishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Lark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
@@ -17,22 +17,22 @@ func setupLoginConfigDir(t *testing.T) {
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -54,13 +54,13 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"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"
|
||||
@@ -102,7 +102,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand brandpkg.Brand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
|
||||
@@ -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", "note"}
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
@@ -25,7 +25,7 @@ type loginScopeCacheRecord struct {
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
return filepath.Join(workspace.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
cfg := &configpkg.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
cfg := &configpkg.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -308,8 +309,8 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "secret", Brand: brandpkg.Feishu,
|
||||
})
|
||||
// TestFactory has IsTerminal=false by default
|
||||
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
|
||||
@@ -600,21 +601,21 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -696,7 +697,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
cfg, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -716,21 +717,21 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -847,15 +848,15 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
@@ -878,7 +879,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()
|
||||
@@ -886,15 +887,15 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -945,25 +946,22 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
@@ -996,11 +994,11 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1070,11 +1068,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
@@ -1108,11 +1106,11 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brandpkg.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
@@ -44,7 +45,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
func authLogoutRun(opts *LogoutOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
@@ -73,7 +74,7 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
appSecret, secretErr := secret.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
@@ -94,8 +95,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
app.Users = []configpkg.AppUser{}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
|
||||
@@ -9,22 +9,24 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
func writeLogoutConfig(t *testing.T, users []configpkg.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("test-secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
@@ -91,7 +93,7 @@ func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
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"}})
|
||||
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
@@ -127,7 +129,7 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
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"}})
|
||||
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
@@ -153,19 +155,19 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -177,11 +179,11 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -210,7 +212,7 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -224,19 +226,19 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -247,11 +249,11 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -280,7 +282,7 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -294,19 +296,19 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -318,11 +320,11 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -346,7 +348,7 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
@@ -45,8 +46,8 @@ func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
||||
@@ -31,10 +32,10 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
// and reach the getAppInfoFn call.
|
||||
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
Brand: brand.Feishu,
|
||||
})
|
||||
return &ScopesOptions{
|
||||
Factory: f,
|
||||
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
@@ -38,8 +39,8 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
|
||||
46
cmd/auth/testmain_test.go
Normal file
46
cmd/auth/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
94
cmd/build.go
94
cmd/build.go
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
@@ -19,12 +20,15 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -33,9 +37,25 @@ 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
|
||||
startupBrand brandpkg.Brand
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
// any command registration touches the runtime catalog. Without it the
|
||||
// registry's sync.Once locks onto the Feishu default at first catalog access,
|
||||
// long before the lazily-resolved config brand is known — see
|
||||
// ResolveStartupBrand for the caller-side resolution.
|
||||
func WithStartupBrand(brand brandpkg.Brand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
}
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -75,6 +95,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
|
||||
@@ -113,6 +168,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
@@ -130,6 +191,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
|
||||
@@ -150,21 +215,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))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// BindOptions holds all inputs for config bind.
|
||||
@@ -128,8 +131,8 @@ func configBindRun(opts *BindOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
core.SetCurrentWorkspace(core.Workspace(source))
|
||||
targetConfigPath := core.GetConfigPath()
|
||||
workspace.SetCurrentWorkspace(workspace.Workspace(source))
|
||||
targetConfigPath := workspace.GetConfigPath()
|
||||
|
||||
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
|
||||
if err != nil {
|
||||
@@ -186,12 +189,12 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
}
|
||||
|
||||
var detected string
|
||||
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case core.WorkspaceOpenClaw:
|
||||
switch workspace.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case workspace.WorkspaceOpenClaw:
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
case workspace.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
case workspace.WorkspaceLarkChannel:
|
||||
detected = "lark-channel"
|
||||
}
|
||||
|
||||
@@ -212,10 +215,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
|
||||
@@ -267,7 +267,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
||||
// enumerate candidates, pick one via the shared decision layer, and build a
|
||||
// ready-to-persist AppConfig. Adding a new bind source only requires
|
||||
// implementing SourceBinder — none of the logic below needs to change.
|
||||
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
|
||||
func resolveAccount(opts *BindOptions, source string) (*configpkg.AppConfig, error) {
|
||||
binder, err := newBinder(source, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,12 +310,12 @@ func resolveIdentity(opts *BindOptions) error {
|
||||
// the bind flow treats a corrupt previous config (commitBinding will
|
||||
// overwrite it cleanly).
|
||||
func hasStrictBotLock(data []byte) bool {
|
||||
var multi core.MultiAppConfig
|
||||
var multi configpkg.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
|
||||
if app.StrictMode != nil && *app.StrictMode == identity.StrictModeBot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -372,16 +372,16 @@ func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
||||
return prior
|
||||
}
|
||||
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||
func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
sm := core.StrictModeBot
|
||||
sm := identity.StrictModeBot
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsBot
|
||||
appConfig.DefaultAs = identity.AsBot
|
||||
case "user-default":
|
||||
sm := core.StrictModeOff
|
||||
sm := identity.StrictModeOff
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
appConfig.DefaultAs = identity.AsUser
|
||||
}
|
||||
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
|
||||
// wrong profile's preference into a re-bind when the workspace holds multiple
|
||||
// named profiles and the active one disagrees with Apps[0].
|
||||
func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
var multi core.MultiAppConfig
|
||||
var multi configpkg.MultiAppConfig
|
||||
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -407,10 +407,10 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
// any), and a JSON success envelope. Cleanup runs only after the new config
|
||||
// is durably written — if anything fails earlier, the old workspace stays
|
||||
// usable.
|
||||
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(workspace.GetConfigDir(), 0700); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
@@ -479,8 +479,8 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
// the secret that ForStorage just wrote (old and new secret share the same
|
||||
// keychain key, derived from appId). Best-effort: errors are silently
|
||||
// ignored (same contract as config init's cleanup).
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
|
||||
var multi core.MultiAppConfig
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *configpkg.AppConfig) {
|
||||
var multi configpkg.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
|
||||
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
|
||||
continue
|
||||
}
|
||||
core.RemoveSecretStore(app.AppSecret, kc)
|
||||
secret.RemoveSecretStore(app.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,13 +506,13 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
detected := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
detected := workspace.DetectWorkspaceFromEnv(os.Getenv)
|
||||
switch detected {
|
||||
case core.WorkspaceOpenClaw:
|
||||
case workspace.WorkspaceOpenClaw:
|
||||
source = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
case workspace.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
case workspace.WorkspaceLarkChannel:
|
||||
source = "lark-channel"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
@@ -585,7 +585,7 @@ func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, er
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
if data, err := vfs.ReadFile(configPath); err == nil {
|
||||
var multi core.MultiAppConfig
|
||||
var multi configpkg.MultiAppConfig
|
||||
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
|
||||
app := multi.Apps[0]
|
||||
existingSummary = fmt.Sprintf(msg.ConflictDesc,
|
||||
|
||||
@@ -13,42 +13,40 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// 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 +57,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
|
||||
@@ -86,8 +84,8 @@ func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) {
|
||||
// Must be called at the start of any test that may trigger configBindRun (which sets workspace).
|
||||
func saveWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
|
||||
orig := workspace.CurrentWorkspace()
|
||||
t.Cleanup(func() { workspace.SetCurrentWorkspace(orig) })
|
||||
}
|
||||
|
||||
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
|
||||
@@ -179,15 +177,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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -229,7 +233,7 @@ func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
|
||||
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -265,7 +269,7 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Fatalf("re-bind (no --lang): %v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -279,9 +283,9 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||
// workspace (set up via `profile add` before a re-bind), the active profile's
|
||||
// Lang must win over a sibling profile that happens to sit earlier in the slice.
|
||||
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
||||
multi := core.MultiAppConfig{
|
||||
multi := configpkg.MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
|
||||
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
|
||||
},
|
||||
@@ -300,8 +304,8 @@ func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
||||
// so a bind-written config (which always has exactly one app and no
|
||||
// CurrentApp field) still inherits its Lang.
|
||||
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
|
||||
multi := core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
multi := configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{AppId: "cli_only", Lang: i18n.LangJaJP},
|
||||
},
|
||||
}
|
||||
@@ -365,7 +369,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 +386,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 +425,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 +441,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 +570,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 +588,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",
|
||||
@@ -639,8 +643,8 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
// Brand is not in the stdout envelope — read it back from the persisted
|
||||
// workspace config to verify accounts.app.tenant flowed through to the
|
||||
// stored AppConfig.Brand field.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
@@ -686,8 +690,8 @@ func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
@@ -731,7 +735,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 +754,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 +774,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 +793,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",
|
||||
@@ -801,16 +805,16 @@ func TestConfigShowRun_WorkspaceField(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLocal)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_local_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
@@ -827,7 +831,7 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
@@ -835,17 +839,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)
|
||||
@@ -914,25 +920,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "feishu"},
|
||||
{"feishu", "feishu"},
|
||||
{"lark", "lark"},
|
||||
{"LARK", "lark"},
|
||||
{" lark ", "lark"},
|
||||
{"Lark", "lark"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeBrand(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
||||
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
||||
custom := filepath.Join(t.TempDir(), "custom.json")
|
||||
@@ -1015,7 +1002,7 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
var multi core.MultiAppConfig
|
||||
var multi configpkg.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
t.Fatalf("unmarshal config.json: %v", err)
|
||||
}
|
||||
@@ -1025,8 +1012,8 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
|
||||
if multi.Apps[0].AppId != "cli_hermes_abc" {
|
||||
t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc")
|
||||
}
|
||||
if multi.Apps[0].Brand != core.BrandLark {
|
||||
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark)
|
||||
if multi.Apps[0].Brand != brand.Lark {
|
||||
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, brand.Lark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,7 +1174,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 +1190,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 +1217,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 +1237,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`,
|
||||
})
|
||||
@@ -1292,7 +1279,7 @@ func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) {
|
||||
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")),
|
||||
})
|
||||
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
||||
core.StrictModeBot, core.AsBot)
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default
|
||||
@@ -1327,7 +1314,7 @@ func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) {
|
||||
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")),
|
||||
})
|
||||
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
||||
core.StrictModeBot, core.AsBot)
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the
|
||||
@@ -1423,7 +1410,7 @@ func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) {
|
||||
t.Fatalf("expected --force to allow the escalation, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
core.StrictModeOff, core.AsUser)
|
||||
identity.StrictModeOff, identity.AsUser)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same
|
||||
@@ -1459,7 +1446,7 @@ func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) {
|
||||
t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
core.StrictModeBot, core.AsBot)
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the
|
||||
@@ -1496,18 +1483,18 @@ func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) {
|
||||
t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
core.StrictModeOff, core.AsUser)
|
||||
identity.StrictModeOff, identity.AsUser)
|
||||
}
|
||||
|
||||
// assertPresetApplied verifies the on-disk config.json applied the identity
|
||||
// preset's StrictMode + DefaultAs expansion.
|
||||
func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) {
|
||||
func assertPresetApplied(t *testing.T, configPath string, wantStrict identity.StrictMode, wantDefault identity.Identity) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", configPath, err)
|
||||
}
|
||||
var multi core.MultiAppConfig
|
||||
var multi configpkg.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", configPath, err)
|
||||
}
|
||||
@@ -1536,7 +1523,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 +1543,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 +1569,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 +1597,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 +1659,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",
|
||||
@@ -1804,10 +1791,10 @@ func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) {
|
||||
}
|
||||
|
||||
oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`)
|
||||
newApp := &core.AppConfig{
|
||||
newApp := &configpkg.AppConfig{
|
||||
AppId: "cli_shared",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: sharedID},
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: sharedID},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1834,10 +1821,10 @@ func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T
|
||||
}
|
||||
|
||||
oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`)
|
||||
newApp := &core.AppConfig{
|
||||
newApp := &configpkg.AppConfig{
|
||||
AppId: "cli_new",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: newID},
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: newID},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/openclawbind"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -36,7 +38,7 @@ type SourceBinder interface {
|
||||
ListCandidates() ([]Candidate, error)
|
||||
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
|
||||
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
|
||||
Build(appID string) (*core.AppConfig, error)
|
||||
Build(appID string) (*configpkg.AppConfig, error)
|
||||
}
|
||||
|
||||
// newBinder constructs the SourceBinder for the given source name.
|
||||
@@ -138,15 +140,15 @@ type openclawBinder struct {
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read / re-parse.
|
||||
cfg *binding.OpenClawRoot
|
||||
rawApps []binding.CandidateApp
|
||||
cfg *openclawbind.OpenClawRoot
|
||||
rawApps []openclawbind.CandidateApp
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Name() string { return "openclaw" }
|
||||
func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
cfg, err := openclawbind.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify OpenClaw is installed and configured").
|
||||
@@ -157,7 +159,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
WithHint("configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
raw := openclawbind.ListCandidateApps(cfg.Channels.Feishu)
|
||||
b.cfg = cfg
|
||||
b.rawApps = raw
|
||||
|
||||
@@ -168,12 +170,12 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *binding.CandidateApp
|
||||
var selected *openclawbind.CandidateApp
|
||||
for i := range b.rawApps {
|
||||
if b.rawApps[i].AppID == appID {
|
||||
selected = &b.rawApps[i]
|
||||
@@ -188,24 +190,24 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
secret, err := openclawbind.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
stored, err := secretpkg.ForStorage(selected.AppID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
return &configpkg.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
Brand: brand.ParseBrand(selected.Brand),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -238,7 +240,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
@@ -251,17 +253,17 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
return &configpkg.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
Brand: brand.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -274,14 +276,14 @@ type larkChannelBinder struct {
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read the file.
|
||||
cfg *binding.LarkChannelRoot
|
||||
cfg *openclawbind.LarkChannelRoot
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Name() string { return "lark-channel" }
|
||||
func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
cfg, err := openclawbind.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify lark-channel-bridge is installed and configured").
|
||||
@@ -295,7 +297,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
@@ -309,24 +311,24 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
secret, err := openclawbind.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
return &configpkg.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
Brand: brand.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -350,16 +352,6 @@ func sourceDisplayName(source string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -20,10 +20,10 @@ type fakeBinder struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*configpkg.AppConfig, error) { return nil, nil }
|
||||
|
||||
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
|
||||
// guardrail that proves the non-TUI decision paths really do stay out of the
|
||||
@@ -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",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -31,12 +31,13 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
return core.ParseBrand(value)
|
||||
func parseBrand(value string) brand.Brand {
|
||||
return brand.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,16 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -65,8 +68,8 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
var gotOpts *ConfigShowOptions
|
||||
@@ -92,31 +95,31 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -185,18 +188,18 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
|
||||
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
|
||||
existing := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{
|
||||
{AppId: "cli_x", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Lang: i18n.LangJaJP},
|
||||
}}
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(existing); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||
if err := saveInitConfig("", existing, f, "cli_x", secret.PlainSecret("s2"), brand.Feishu, ""); err != nil {
|
||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
got, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -233,15 +236,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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -311,17 +320,17 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -350,7 +359,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -370,42 +379,72 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", secret.PlainSecret("new-secret"), brand.Lark, "en")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.SecretInput{Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: brand.Feishu,
|
||||
Lang: "zh",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", brand.Lark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,14 +21,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return core.NoActiveProfileError()
|
||||
return configpkg.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
@@ -44,8 +45,8 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
app.DefaultAs = identity.Identity(value)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
|
||||
@@ -6,22 +6,23 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// ConfigInitOptions holds all inputs for config init.
|
||||
@@ -123,16 +124,13 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
ws := workspace.DetectWorkspaceFromEnv(os.Getenv)
|
||||
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.
|
||||
@@ -141,7 +139,7 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
|
||||
func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
@@ -149,7 +147,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
if app.AppId == skipAppID {
|
||||
continue
|
||||
}
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
secretpkg.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
@@ -157,19 +155,19 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
}
|
||||
|
||||
// saveAsOnlyApp overwrites config.json with a single-app config.
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
func saveAsOnlyApp(appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
config := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []configpkg.AppUser{},
|
||||
}},
|
||||
}
|
||||
return core.SaveMultiAppConfig(config)
|
||||
return configpkg.SaveMultiAppConfig(config)
|
||||
}
|
||||
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
func saveInitConfig(profileName string, existing *configpkg.MultiAppConfig, f *cmdutil.Factory, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
@@ -183,23 +181,37 @@ 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.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
multi = &configpkg.MultiAppConfig{}
|
||||
}
|
||||
|
||||
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
|
||||
// Clean up old keychain secret and user tokens if AppId changed
|
||||
if multi.Apps[idx].AppId != appId {
|
||||
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
secretpkg.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
for _, user := range multi.Apps[idx].Users {
|
||||
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
multi.Apps[idx].Users = []configpkg.AppUser{}
|
||||
}
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
@@ -207,22 +219,24 @@ 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{
|
||||
multi.Apps = append(multi.Apps, configpkg.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
Users: []configpkg.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
return configpkg.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
@@ -234,7 +248,7 @@ func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
func findAppIndexByAppID(multi *configpkg.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
@@ -249,8 +263,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,20 +272,16 @@ 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)
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, profileName, appID string, brand brandpkg.Brand, lang string) error {
|
||||
if existing == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
var app *configpkg.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
@@ -295,7 +305,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
return configpkg.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
@@ -316,14 +326,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := core.LoadMultiAppConfig()
|
||||
existing, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
if err := configpkg.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
@@ -331,14 +341,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||
secret, err := secretpkg.ForStorage(opts.AppID, secretpkg.PlainSecret(opts.appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
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()))
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.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 {
|
||||
@@ -353,10 +363,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
|
||||
@@ -373,13 +380,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
}
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
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})
|
||||
@@ -400,16 +407,16 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
|
||||
if result.AppSecret != "" {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
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
|
||||
@@ -439,7 +446,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
firstApp := (*configpkg.AppConfig)(nil)
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
@@ -490,9 +497,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if resolvedAppId == "" && firstApp != nil {
|
||||
resolvedAppId = firstApp.AppId
|
||||
}
|
||||
var resolvedSecret core.SecretInput
|
||||
var resolvedSecret secretpkg.SecretInput
|
||||
if appSecretInput != "" {
|
||||
resolvedSecret = core.PlainSecret(appSecretInput)
|
||||
resolvedSecret = secretpkg.PlainSecret(appSecretInput)
|
||||
} else if firstApp != nil {
|
||||
resolvedSecret = firstApp.AppSecret
|
||||
}
|
||||
@@ -509,14 +516,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
storedSecret, err := secretpkg.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
if err != nil {
|
||||
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()))
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,19 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
@@ -22,7 +25,7 @@ import (
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
type configInitResult struct {
|
||||
Mode string // "create" or "existing"
|
||||
Brand core.LarkBrand
|
||||
Brand brand.Brand
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
@@ -60,8 +63,8 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init
|
||||
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
|
||||
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
|
||||
// Load existing config for defaults
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
var firstApp *configpkg.AppConfig
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
@@ -148,8 +151,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand core.LarkBrand
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride brand.Brand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand brand.Brand
|
||||
if brandOverride != "" {
|
||||
larkBrand = brandOverride
|
||||
} else {
|
||||
@@ -180,9 +183,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
return nil, classifyRegistrationBeginError(err)
|
||||
}
|
||||
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
@@ -208,33 +211,17 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
// Step 4: Poll for credentials (brand discovery lives in internal/auth);
|
||||
// this layer only classifies the terminal error and saves the result.
|
||||
result, finalBrand, err := larkauth.RegisterAppWithDiscovery(ctx, httpClient, authResp, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
return nil, classifyRegistrationError(err)
|
||||
}
|
||||
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
finalBrand := larkBrand
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
||||
|
||||
@@ -245,3 +232,40 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
AppSecret: result.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classifyRegistrationBeginError keeps transport/cancellation failures out of
|
||||
// the invalid-client category: the begin request sends no app credentials.
|
||||
func classifyRegistrationBeginError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration cancelled").WithCause(err)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "app registration begin timed out: %v", err).WithCause(err)
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if netErr.Timeout() {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// classifyRegistrationError maps registration terminal outcomes to typed
|
||||
// errors, preserving causes.
|
||||
func classifyRegistrationError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, larkauth.ErrRegistrationDenied):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and approve the authorization request").
|
||||
WithCause(err)
|
||||
case errors.Is(err, larkauth.ErrRegistrationExpired), errors.Is(err, larkauth.ErrRegistrationTimedOut):
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenExpired, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and complete the scan before the code expires").
|
||||
WithCause(err)
|
||||
default:
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
70
cmd/config/init_interactive_test.go
Normal file
70
cmd/config/init_interactive_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
func assertRegistrationProblem(t *testing.T, got, cause error, category errs.Category, subtype errs.Subtype) *errs.Problem {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("error %T is not typed: %v", got, got)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Errorf("problem = (%q, %q), want (%q, %q)", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Errorf("error %v does not preserve cause %v", got, cause)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationBeginError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{"cancelled", context.Canceled, errs.CategoryAuthentication, errs.SubtypeUnknown},
|
||||
{"deadline", context.DeadlineExceeded, errs.CategoryNetwork, errs.SubtypeNetworkTimeout},
|
||||
{"transport", &net.DNSError{Err: "lookup failed", Name: "accounts.example"}, errs.CategoryNetwork, errs.SubtypeNetworkTransport},
|
||||
{"response", errors.New("response not JSON"), errs.CategoryAPI, errs.SubtypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assertRegistrationProblem(t, classifyRegistrationBeginError(tt.err), tt.err, tt.category, tt.subtype)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
hint bool
|
||||
}{
|
||||
{"denied", larkauth.ErrRegistrationDenied, errs.SubtypeUnknown, true},
|
||||
{"expired", larkauth.ErrRegistrationExpired, errs.SubtypeTokenExpired, true},
|
||||
{"timed-out", larkauth.ErrRegistrationTimedOut, errs.SubtypeTokenExpired, true},
|
||||
{"cancelled", context.Canceled, errs.SubtypeUnknown, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := assertRegistrationProblem(t, classifyRegistrationError(tt.err), tt.err, errs.CategoryAuthentication, tt.subtype)
|
||||
if (p.Hint != "") != tt.hint {
|
||||
t.Errorf("hint = %q, want non-empty=%v", p.Hint, tt.hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,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)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ const probeTimeout = 3 * time.Second
|
||||
// 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 {
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand brandpkg.Brand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
url := brandpkg.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 {
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"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.
|
||||
@@ -132,7 +132,7 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
@@ -148,7 +148,7 @@ func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
@@ -161,7 +161,7 @@ func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
@@ -201,7 +201,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
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)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
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 {
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
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 {
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Lark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
@@ -262,7 +262,7 @@ func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
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)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
@@ -275,7 +275,7 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
@@ -19,54 +21,54 @@ import (
|
||||
// not for missing user input.
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", brand.Feishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", brand.Feishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", brand.Feishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", brand.Feishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
// 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 +92,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)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,21 +42,21 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
config, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
||||
}
|
||||
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
empty := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{}}
|
||||
if err := configpkg.SaveMultiAppConfig(empty); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
secret.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
132
cmd/config/risk_control_test.go
Normal file
132
cmd/config/risk_control_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
}}}
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
}}}
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -43,15 +44,15 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
config, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return core.NotConfiguredError()
|
||||
return configpkg.NotConfiguredError()
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
return configpkg.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -66,7 +67,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"workspace": workspace.CurrentWorkspace().Display(),
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
@@ -74,6 +75,6 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
"lang": app.Lang,
|
||||
"users": users,
|
||||
})
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", workspace.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -45,20 +46,20 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return core.NoActiveProfileError()
|
||||
return configpkg.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return core.NoActiveProfileError()
|
||||
return configpkg.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return core.NoActiveProfileError()
|
||||
return configpkg.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -71,7 +72,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
||||
}
|
||||
@@ -79,14 +80,14 @@ func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.A
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) error {
|
||||
// Runtime effective mode from credential provider chain is the source of truth.
|
||||
runtime := f.ResolveStrictMode(ctx)
|
||||
configMode, configSource := resolveStrictModeStatus(multi, app)
|
||||
@@ -99,10 +100,10 @@ func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAp
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, value string, global bool) error {
|
||||
mode := identity.StrictMode(value)
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
case identity.StrictModeBot, identity.StrictModeUser, identity.StrictModeOff:
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
@@ -118,7 +119,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
var oldMode identity.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
@@ -138,16 +139,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return core.NoActiveProfileError()
|
||||
return configpkg.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
if oldMode == identity.StrictModeBot && (mode == identity.StrictModeUser || mode == identity.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
@@ -162,19 +163,19 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
func strictModeRelaxLang(app *configpkg.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
func resolveStrictModeStatus(multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) (identity.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
}
|
||||
if multi.StrictMode.IsActive() {
|
||||
return multi.StrictMode, "global"
|
||||
}
|
||||
return core.StrictModeOff, "global (default)"
|
||||
return identity.StrictModeOff, "global (default)"
|
||||
}
|
||||
|
||||
@@ -7,29 +7,32 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Show_Default(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
@@ -42,37 +45,37 @@ func TestStrictMode_Show_Default(t *testing.T) {
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeBot {
|
||||
t.Error("expected StrictMode=bot on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetUser_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"user"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeUser {
|
||||
t.Error("expected StrictMode=user on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
@@ -81,23 +84,23 @@ func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeOff {
|
||||
t.Error("expected StrictMode=off on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi.StrictMode != core.StrictModeBot {
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
if multi.StrictMode != identity.StrictModeBot {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
@@ -105,38 +108,38 @@ func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != core.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
|
||||
if saved.StrictMode != identity.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, identity.StrictModeBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Reset(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
@@ -145,7 +148,7 @@ func TestStrictMode_Reset(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
@@ -154,7 +157,7 @@ func TestStrictMode_Reset(t *testing.T) {
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
|
||||
@@ -14,13 +14,16 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// DoctorOptions holds inputs for the doctor command.
|
||||
@@ -84,7 +87,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
_, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
@@ -94,8 +97,8 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
@@ -108,7 +111,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
|
||||
}
|
||||
@@ -117,7 +120,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
}
|
||||
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
ep := brand.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
@@ -128,7 +131,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 ──
|
||||
@@ -145,7 +151,7 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep brand.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
return []checkResult{
|
||||
skip("endpoint_open", "skipped (--offline)"),
|
||||
@@ -235,7 +241,7 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"workspace": workspace.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
|
||||
@@ -4,19 +4,26 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdDoctor(f)
|
||||
@@ -83,7 +90,7 @@ func TestFinishDoctor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkChecks_Offline(t *testing.T) {
|
||||
ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
|
||||
ep := brand.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
|
||||
opts := &DoctorOptions{Ctx: context.Background(), Offline: true}
|
||||
checks := networkChecks(opts.Ctx, opts, ep)
|
||||
if len(checks) != 2 {
|
||||
@@ -98,22 +105,22 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
@@ -140,14 +147,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 := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu}},
|
||||
}); 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 := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, 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() (*configpkg.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
"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"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -49,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.
|
||||
@@ -85,9 +58,9 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
identity = string(identitypkg.AsUser)
|
||||
}
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
if identity != string(identitypkg.AsUser) && identity != string(identitypkg.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -157,7 +130,7 @@ func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
authTypes = []string{string(identitypkg.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
@@ -35,7 +35,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
@@ -24,8 +25,8 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: brand.Feishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
@@ -4,21 +4,118 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// 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 brandpkg.Brand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := brandpkg.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 brandpkg.Brand, appID string) string {
|
||||
host := brandpkg.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 brandpkg.Brand, 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 identitypkg.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 identitypkg.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,110 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/brand"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
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(brand.Feishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := brand.ResolveEndpoints(brand.Feishu).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(identity.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(identity.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, identity.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, identity.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(identity.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(identity.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, identity.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,15 +16,16 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -118,7 +119,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
domain := brandpkg.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
@@ -131,7 +132,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: identitypkg.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
@@ -146,14 +147,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
|
||||
@@ -210,8 +225,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (identitypkg.Identity, error) {
|
||||
flagAs := identitypkg.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
@@ -224,11 +239,14 @@ func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.Ke
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
brand brandpkg.Brand
|
||||
eventKey string
|
||||
identity core.Identity
|
||||
identity identitypkg.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).
|
||||
@@ -247,7 +265,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == core.AsUser:
|
||||
case pf.identity == identitypkg.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
@@ -266,46 +284,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
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 brandpkg.Brand, appID string, identity identitypkg.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
|
||||
}
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
"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).
|
||||
@@ -331,7 +369,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identitypkg.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
@@ -349,9 +387,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -270,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
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func errorAs(err error, target interface{}) bool {
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
|
||||
@@ -9,13 +9,27 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
@@ -24,8 +38,13 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -34,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
@@ -55,4 +74,33 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
func newPreflightCtx(appID string, brand brandpkg.Brand, identity identitypkg.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
@@ -97,9 +98,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
@@ -157,9 +158,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
}
|
||||
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) {
|
||||
@@ -170,7 +170,113 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: brandpkg.Feishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.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: brandpkg.Feishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.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: brandpkg.Feishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.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: brandpkg.Feishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.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(brandpkg.Feishu, "cli_x", identitypkg.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(brandpkg.Feishu, "cli_x", identitypkg.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity core.Identity
|
||||
accessIdentity identity.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
@@ -45,9 +47,9 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
accessIdentity: identity.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,38 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
@@ -40,7 +63,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
@@ -60,7 +83,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
@@ -76,7 +99,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -96,6 +119,161 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.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_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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, &configpkg.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) })
|
||||
@@ -110,7 +288,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
@@ -156,7 +334,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
// until at least two profiles exist. Intended for the Execute entry point —
|
||||
// buildInternal must not call this directly to stay state-free.
|
||||
func isSingleAppMode() bool {
|
||||
raw, err := core.LoadMultiAppConfig()
|
||||
raw, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil || raw == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -58,8 +60,8 @@ func TestIsSingleAppMode_NoConfig(t *testing.T) {
|
||||
|
||||
func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
saveAppsForTest(t, []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
|
||||
})
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true for single-app config")
|
||||
@@ -68,9 +70,9 @@ func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
|
||||
func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
|
||||
saveAppsForTest(t, []configpkg.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: secret.PlainSecret("y"), Brand: brand.Feishu},
|
||||
})
|
||||
if isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
|
||||
@@ -101,10 +103,10 @@ func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
|
||||
func saveAppsForTest(t *testing.T, apps []configpkg.AppConfig) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
multi := &configpkg.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
@@ -261,7 +261,7 @@ func splitCSV(s string) []string {
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// workspace.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
@@ -271,7 +271,7 @@ func splitCSV(s string) []string {
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
return filepath.Join(workspace.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
|
||||
@@ -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,10 +12,13 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
@@ -52,8 +55,10 @@ 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)
|
||||
if err := configpkg.ValidateProfileName(name); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
|
||||
WithCause(err).
|
||||
WithParam("--name")
|
||||
}
|
||||
|
||||
langPref, err := cmdutil.ParseLangFlag(lang)
|
||||
@@ -64,49 +69,60 @@ 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()
|
||||
multi, err := configpkg.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{}
|
||||
multi = &configpkg.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)
|
||||
secret, err := secretpkg.ForStorage(appID, secretpkg.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)
|
||||
parsedBrand := brandpkg.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
@@ -117,13 +133,13 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
multi.Apps = append(multi.Apps, configpkg.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
Users: []configpkg.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
@@ -133,8 +149,8 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
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,20 +9,22 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand brand.Brand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
@@ -39,13 +41,13 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
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,10 +11,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -50,6 +53,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:
|
||||
@@ -64,7 +77,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
|
||||
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -81,7 +94,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
saved, _ := core.LoadMultiAppConfig()
|
||||
saved, _ := configpkg.LoadMultiAppConfig()
|
||||
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
|
||||
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
|
||||
}
|
||||
@@ -95,22 +108,22 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -121,7 +134,7 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -138,15 +151,15 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
PreviousApp: "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},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -155,7 +168,7 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -172,17 +185,17 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -191,7 +204,7 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -208,17 +221,17 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -227,7 +240,7 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -244,15 +257,15 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
PreviousApp: "target",
|
||||
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},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +274,7 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -275,14 +288,14 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.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},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -328,14 +341,14 @@ func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
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},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -353,16 +366,16 @@ func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -380,14 +393,14 @@ func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.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},
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -406,17 +419,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 := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
},
|
||||
}
|
||||
if err := configpkg.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 := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "solo",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "solo", AppId: "app-solo", AppSecret: secret.PlainSecret("secret-solo"), Brand: brand.Feishu},
|
||||
},
|
||||
}
|
||||
if err := configpkg.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,10 +9,12 @@ 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"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
@@ -33,18 +35,19 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -64,12 +67,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
secret.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -29,18 +30,18 @@ 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)
|
||||
if err := configpkg.ValidateProfileName(newName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +67,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
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,8 +9,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -32,7 +33,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -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()
|
||||
@@ -65,8 +67,8 @@ 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)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
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))
|
||||
|
||||
35
cmd/prune.go
35
cmd/prune.go
@@ -9,14 +9,14 @@ 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"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
func pruneForStrictMode(root *cobra.Command, mode identity.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
// identities incompatible with the forced identity. Commands without annotation are kept.
|
||||
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
||||
// of Cobra's generic "unknown flag" fallback from the parent command.
|
||||
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
func pruneIncompatible(parent *cobra.Command, mode identity.StrictMode) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
@@ -44,7 +44,7 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
func strictModeStubFrom(child *cobra.Command, mode identity.StrictMode) *cobra.Command {
|
||||
// The denial annotations let the hook layer's populateInvocationDenial
|
||||
// recognise this command as denied, so the Wrap chain is physically
|
||||
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
||||
@@ -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,10 +8,11 @@ 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"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -74,7 +75,7 @@ func findCmd(root *cobra.Command, names ...string) *cobra.Command {
|
||||
|
||||
func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
|
||||
@@ -98,7 +99,7 @@ func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
|
||||
func TestPruneForStrictMode_User(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
pruneForStrictMode(root, identity.StrictModeUser)
|
||||
|
||||
if findCmd(root, "im", "+search") == nil {
|
||||
t.Error("+search (user-only) should be kept in user mode")
|
||||
@@ -116,7 +117,7 @@ func TestPruneForStrictMode_User(t *testing.T) {
|
||||
|
||||
func TestPruneEmpty(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
|
||||
@@ -143,7 +144,7 @@ func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "+search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
@@ -159,7 +160,7 @@ func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testi
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
@@ -175,7 +176,7 @@ func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
|
||||
|
||||
err := root.Execute()
|
||||
@@ -191,7 +192,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
pruneForStrictMode(root, identity.StrictModeUser)
|
||||
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
|
||||
|
||||
err := root.Execute()
|
||||
@@ -214,7 +215,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
|
||||
// stops at the stub and proceeds to its RunE.
|
||||
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
@@ -234,7 +235,7 @@ func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
|
||||
// stub's RunE.
|
||||
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
@@ -247,12 +248,15 @@ 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)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
@@ -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
|
||||
@@ -314,7 +318,7 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
// and silently return nil, swallowing the strict-mode error.
|
||||
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
|
||||
// im/+search is user-only -> replaced by a stub in StrictModeBot.
|
||||
stub := findCmd(root, "im", "+search")
|
||||
@@ -352,7 +356,7 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
cmdutil.SetRisk(userOnly, "read")
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
pruneForStrictMode(root, identity.StrictModeBot)
|
||||
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
|
||||
480
cmd/root.go
480
cmd/root.go
@@ -11,19 +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/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"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"
|
||||
@@ -33,43 +30,60 @@ 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>
|
||||
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}}
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -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}}
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
More help: lark-cli <command> --help`
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
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
|
||||
@@ -93,6 +107,7 @@ func Execute() int {
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -217,56 +232,37 @@ 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/config), 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
|
||||
@@ -279,58 +275,63 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
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
|
||||
@@ -361,13 +362,10 @@ 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 {
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
@@ -383,28 +381,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
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
|
||||
@@ -416,19 +399,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
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, deprecated := availableSubcommandNames(cmd)
|
||||
@@ -442,27 +419,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
@@ -587,48 +549,78 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
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
|
||||
@@ -681,6 +673,21 @@ 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, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
@@ -698,56 +705,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,31 +8,35 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/envnames"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"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,38 +67,52 @@ 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) {
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
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)
|
||||
}
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -105,7 +123,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -113,37 +135,60 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode identity.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envnames.CliAppID, "")
|
||||
t.Setenv(envnames.CliAppSecret, "")
|
||||
t.Setenv(envnames.CliUserAccessToken, "")
|
||||
t.Setenv(envnames.CliTenantAccessToken, "")
|
||||
t.Setenv(envnames.CliDefaultAs, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
targetMode := mode
|
||||
multi := &core.MultiAppConfig{
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
AppSecret: secret.PlainSecret("secret-target"),
|
||||
Brand: brand.Feishu,
|
||||
StrictMode: &targetMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -164,7 +209,7 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
||||
@@ -196,7 +241,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testin
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
@@ -205,55 +250,95 @@ 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) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"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) {
|
||||
// +chat-create supports both user and bot identities, so strict mode user
|
||||
// should allow it and force user identity.
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
@@ -270,93 +355,84 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "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) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--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) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--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) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"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 ---
|
||||
|
||||
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: brand.Feishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
@@ -372,16 +448,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",
|
||||
Code: 230002,
|
||||
Message: "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
|
||||
|
||||
357
cmd/root_test.go
357
cmd/root_test.go
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
@@ -20,8 +21,9 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -76,11 +78,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +141,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,
|
||||
@@ -269,12 +270,11 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
// 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) })
|
||||
@@ -286,9 +286,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
// 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:") {
|
||||
@@ -297,12 +297,96 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
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_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
// 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/configpkg.
|
||||
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, configpkg.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)
|
||||
@@ -311,9 +395,45 @@ func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,12 +457,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)
|
||||
@@ -374,10 +514,10 @@ func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
|
||||
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
f.ResolvedIdentity = identity.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
@@ -422,10 +562,10 @@ func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *tes
|
||||
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
f.ResolvedIdentity = identity.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
@@ -447,10 +587,10 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi
|
||||
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
f.ResolvedIdentity = identity.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
@@ -473,10 +613,10 @@ func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing
|
||||
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
f.ResolvedIdentity = identity.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
@@ -494,136 +634,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
cmd/root_upgrade.go
Normal file
100
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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
|
||||
}
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", 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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user