mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
215 Commits
docs/drive
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c7f9f390 | ||
|
|
3f993ea772 | ||
|
|
461b4a7e80 | ||
|
|
d6b235aaa2 | ||
|
|
d6dfd1e043 | ||
|
|
3a33794aec | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 | ||
|
|
736b131cdf | ||
|
|
5efaf65aec | ||
|
|
0991da7446 | ||
|
|
80bea45c6a | ||
|
|
c775cb4360 | ||
|
|
824aa9edf8 | ||
|
|
9d4ae94394 | ||
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f | ||
|
|
1f2164c7c2 | ||
|
|
76f5419a0d | ||
|
|
c5b5aece33 | ||
|
|
d687a76c79 | ||
|
|
4a4c3344c8 | ||
|
|
c61acb5264 | ||
|
|
7eeb111a2d | ||
|
|
714da970d0 | ||
|
|
ed7fdd1a27 | ||
|
|
4464ba7660 | ||
|
|
bb03c8ac4d | ||
|
|
3feb70b32a | ||
|
|
64b1b3f3ed | ||
|
|
a0e83c7e59 | ||
|
|
297b2a222e | ||
|
|
80a5f30f4d | ||
|
|
cf35d1e499 | ||
|
|
fd16cf106b | ||
|
|
53076733ec | ||
|
|
a3bee13ca9 | ||
|
|
6217bd2c29 | ||
|
|
72c294712c | ||
|
|
37f4f899b2 | ||
|
|
c0730b46bf | ||
|
|
751092c8ef | ||
|
|
deb0bd9dd6 | ||
|
|
0fbfe68726 | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 | ||
|
|
a4a4bd6ee0 | ||
|
|
ac116e7ca3 | ||
|
|
5e6a3eb857 | ||
|
|
493b3cce95 | ||
|
|
abc0553f21 | ||
|
|
a82a486508 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b | ||
|
|
24ce3ec151 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef | ||
|
|
0aa9e96d18 | ||
|
|
e57d97f341 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 | ||
|
|
bc8e9bd6ef | ||
|
|
f65712cacf | ||
|
|
915cc623cc | ||
|
|
3bfb80951d | ||
|
|
639259fbfd | ||
|
|
0bdd7de807 | ||
|
|
99e314fe0b | ||
|
|
50b3f0a2af | ||
|
|
b1ecf2d0f9 | ||
|
|
d126ea2f92 | ||
|
|
1ba107da2e | ||
|
|
0e6274d947 | ||
|
|
e18ea9a2e8 | ||
|
|
365e0a2880 | ||
|
|
0a2c3202cb | ||
|
|
176d452cc1 | ||
|
|
a2cc5e124e | ||
|
|
a2dde84158 | ||
|
|
21998b9ca8 | ||
|
|
ce2abff8ae | ||
|
|
893555a1b1 | ||
|
|
8d496b8a48 | ||
|
|
01fe71d7db | ||
|
|
3b770558e5 | ||
|
|
3cd84fca90 | ||
|
|
c2e737434c | ||
|
|
b91f6a23f3 | ||
|
|
bbef3cbfb1 | ||
|
|
cdae999541 | ||
|
|
36ff632a13 | ||
|
|
ab94ee9f54 | ||
|
|
30327abacb | ||
|
|
70081f62b1 | ||
|
|
17cbc13fcb | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 | ||
|
|
b783561965 | ||
|
|
f00261da9f | ||
|
|
137176e8b0 | ||
|
|
0bf590d01a | ||
|
|
cf40945bbc | ||
|
|
b9e5b50251 | ||
|
|
049ddf771b | ||
|
|
f12d279fc2 | ||
|
|
83adbac2b2 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/internal/ @liangshuo-1
|
||||||
|
|
||||||
|
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||||
|
/skills/ @liangshuo-1
|
||||||
|
/skills/lark-approval/
|
||||||
|
/skills/lark-apps/
|
||||||
|
/skills/lark-attendance/
|
||||||
|
/skills/lark-base/
|
||||||
|
/skills/lark-calendar/
|
||||||
|
/skills/lark-contact/
|
||||||
|
/skills/lark-doc/
|
||||||
|
/skills/lark-drive/
|
||||||
|
/skills/lark-event/
|
||||||
|
/skills/lark-im/
|
||||||
|
/skills/lark-mail/
|
||||||
|
/skills/lark-markdown/
|
||||||
|
/skills/lark-minutes/
|
||||||
|
/skills/lark-okr/
|
||||||
|
/skills/lark-openapi-explorer/
|
||||||
|
/skills/lark-shared/
|
||||||
|
/skills/lark-sheets/
|
||||||
|
/skills/lark-skill-maker/
|
||||||
|
/skills/lark-slides/
|
||||||
|
/skills/lark-task/
|
||||||
|
/skills/lark-vc/
|
||||||
|
/skills/lark-vc-agent/
|
||||||
|
/skills/lark-whiteboard/
|
||||||
|
/skills/lark-wiki/
|
||||||
|
/skills/lark-workflow-meeting-summary/
|
||||||
|
/skills/lark-workflow-standup-report/
|
||||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
|||||||
## Test Plan
|
## Test Plan
|
||||||
<!-- Describe how this change was verified. -->
|
<!-- Describe how this change was verified. -->
|
||||||
- [ ] Unit tests pass
|
- [ ] Unit tests pass
|
||||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||||
|
|||||||
58
.github/workflows/ci.yml
vendored
58
.github/workflows/ci.yml
vendored
@@ -10,8 +10,6 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||||
@@ -80,8 +78,47 @@ jobs:
|
|||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
- name: Fetch meta data
|
- name: Fetch meta data
|
||||||
run: python3 scripts/fetch_meta.py
|
run: python3 scripts/fetch_meta.py
|
||||||
|
- name: Resolve changed-from baseline
|
||||||
|
env:
|
||||||
|
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||||
|
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||||
|
- name: Run errs/ lint guards (lintcheck)
|
||||||
|
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||||
|
|
||||||
|
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: Run CLI deterministic gate
|
||||||
|
run: 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:
|
coverage:
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
@@ -101,6 +138,7 @@ jobs:
|
|||||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||||
with:
|
with:
|
||||||
files: coverage.txt
|
files: coverage.txt
|
||||||
@@ -182,7 +220,7 @@ jobs:
|
|||||||
|
|
||||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||||
e2e-dry-run:
|
e2e-dry-run:
|
||||||
needs: [unit-test, lint]
|
needs: [unit-test, lint, deterministic-gate]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
@@ -203,9 +241,12 @@ jobs:
|
|||||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||||
|
|
||||||
e2e-live:
|
e2e-live:
|
||||||
needs: [unit-test, lint]
|
needs: [unit-test, lint, deterministic-gate]
|
||||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
env:
|
env:
|
||||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||||
@@ -252,6 +293,9 @@ jobs:
|
|||||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
with:
|
with:
|
||||||
@@ -289,7 +333,7 @@ jobs:
|
|||||||
# ── Results Gate (single required check for branch protection) ─────
|
# ── Results Gate (single required check for branch protection) ─────
|
||||||
results:
|
results:
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Evaluate results
|
- name: Evaluate results
|
||||||
@@ -301,6 +345,7 @@ jobs:
|
|||||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| L2 | lint | ${{ needs.lint.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 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| L2 | deadcode | ${{ needs.deadcode.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-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -316,6 +361,7 @@ jobs:
|
|||||||
"${{ needs.fast-gate.result }}" \
|
"${{ needs.fast-gate.result }}" \
|
||||||
"${{ needs.unit-test.result }}" \
|
"${{ needs.unit-test.result }}" \
|
||||||
"${{ needs.lint.result }}" \
|
"${{ needs.lint.result }}" \
|
||||||
|
"${{ needs.deterministic-gate.result }}" \
|
||||||
"${{ needs.coverage.result }}" \
|
"${{ needs.coverage.result }}" \
|
||||||
"${{ needs.deadcode.result }}" \
|
"${{ needs.deadcode.result }}" \
|
||||||
"${{ needs.e2e-dry-run.result }}" \
|
"${{ needs.e2e-dry-run.result }}" \
|
||||||
|
|||||||
566
.github/workflows/semantic-review.yml
vendored
Normal file
566
.github/workflows/semantic-review.yml
vendored
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
name: Semantic Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-quality-summary:
|
||||||
|
if: github.event.workflow_run.event == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Verify workflow run and pull request for summary
|
||||||
|
id: pr
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = context.payload.workflow_run;
|
||||||
|
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||||
|
let workflowPath = run.path || "";
|
||||||
|
if (!workflowPath) {
|
||||||
|
const workflowId = Number(run.workflow_id || 0);
|
||||||
|
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||||
|
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
workflow_id: workflowId,
|
||||||
|
});
|
||||||
|
workflowPath = workflow.path || "";
|
||||||
|
}
|
||||||
|
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||||
|
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||||
|
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||||
|
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||||
|
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||||
|
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||||
|
if (runPRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||||
|
}
|
||||||
|
let prNumber = Number(runPRs[0]?.number || 0);
|
||||||
|
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||||
|
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||||
|
const targetHeadSha = run.head_sha;
|
||||||
|
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||||
|
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||||
|
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||||
|
}
|
||||||
|
|
||||||
|
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||||
|
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: run.id,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||||
|
let factsArtifactName = "";
|
||||||
|
let artifactBaseSha = "";
|
||||||
|
let artifactError = "";
|
||||||
|
if (factsArtifacts.length !== 1) {
|
||||||
|
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||||
|
} else {
|
||||||
|
factsArtifactName = factsArtifacts[0].name;
|
||||||
|
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||||
|
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||||
|
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||||
|
factsArtifactName = "";
|
||||||
|
} else {
|
||||||
|
artifactBaseSha = parsedBaseSha;
|
||||||
|
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||||
|
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prNumber) {
|
||||||
|
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
commit_sha: targetHeadSha,
|
||||||
|
});
|
||||||
|
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||||
|
candidate.state === "open" &&
|
||||||
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
|
candidate.head?.sha === targetHeadSha
|
||||||
|
);
|
||||||
|
if (candidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||||
|
}
|
||||||
|
if (candidatePRs.length === 1) {
|
||||||
|
prNumber = candidatePRs[0].number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prNumber) {
|
||||||
|
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: "open",
|
||||||
|
per_page: 100,
|
||||||
|
}).then((prs) => prs.filter((candidate) =>
|
||||||
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
|
candidate.head?.sha === targetHeadSha
|
||||||
|
));
|
||||||
|
if (candidatePRs.length !== 1) {
|
||||||
|
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||||
|
}
|
||||||
|
prNumber = candidatePRs[0].number;
|
||||||
|
}
|
||||||
|
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.head.sha !== targetHeadSha) {
|
||||||
|
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||||
|
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||||
|
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||||
|
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (artifactError) {
|
||||||
|
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
|
||||||
|
}
|
||||||
|
core.setOutput("pr_number", String(prNumber));
|
||||||
|
core.setOutput("head_sha", targetHeadSha);
|
||||||
|
core.setOutput("base_sha", baseSha);
|
||||||
|
core.setOutput("run_id", String(run.id));
|
||||||
|
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||||
|
core.setOutput("artifact_error", artifactError);
|
||||||
|
core.setOutput("stale", "false");
|
||||||
|
|
||||||
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
id: checkout
|
||||||
|
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.pr.outputs.base_sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify summary facts artifact metadata
|
||||||
|
id: artifact
|
||||||
|
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = context.payload.workflow_run;
|
||||||
|
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||||
|
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: run.id,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||||
|
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||||
|
const artifact = artifacts[0];
|
||||||
|
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||||
|
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||||
|
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||||
|
}
|
||||||
|
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||||
|
core.setOutput("artifact_id", String(artifact.id));
|
||||||
|
core.setOutput("artifact_digest", artifact.digest);
|
||||||
|
|
||||||
|
- name: Download facts artifact zip
|
||||||
|
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
id: download
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||||
|
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||||
|
const { data } = await github.rest.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: artifactId,
|
||||||
|
archive_format: "zip",
|
||||||
|
});
|
||||||
|
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||||
|
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||||
|
core.setOutput("zip_path", zipPath);
|
||||||
|
|
||||||
|
- name: Verify and extract summary facts artifact
|
||||||
|
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
|
||||||
|
env:
|
||||||
|
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||||
|
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||||
|
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||||
|
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||||
|
|
||||||
|
- name: Publish PR quality summary
|
||||||
|
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||||
|
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||||
|
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||||
|
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||||
|
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { publish } = require("./scripts/ci-quality-summary-publish.js");
|
||||||
|
await publish({ github, context, core });
|
||||||
|
|
||||||
|
semantic-review:
|
||||||
|
needs: pr-quality-summary
|
||||||
|
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
checks: write
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Verify workflow run and pull request
|
||||||
|
id: pr
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = context.payload.workflow_run;
|
||||||
|
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||||
|
let workflowPath = run.path || "";
|
||||||
|
if (!workflowPath) {
|
||||||
|
const workflowId = Number(run.workflow_id || 0);
|
||||||
|
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||||
|
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
workflow_id: workflowId,
|
||||||
|
});
|
||||||
|
workflowPath = workflow.path || "";
|
||||||
|
}
|
||||||
|
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||||
|
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||||
|
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||||
|
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||||
|
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||||
|
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||||
|
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||||
|
if (runPRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||||
|
}
|
||||||
|
let prNumber = Number(runPRs[0]?.number || 0);
|
||||||
|
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||||
|
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||||
|
const targetHeadSha = run.head_sha;
|
||||||
|
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||||
|
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||||
|
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||||
|
}
|
||||||
|
|
||||||
|
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||||
|
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: run.id,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||||
|
let factsArtifactName = "";
|
||||||
|
let artifactBaseSha = "";
|
||||||
|
let artifactError = "";
|
||||||
|
if (factsArtifacts.length !== 1) {
|
||||||
|
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||||
|
} else {
|
||||||
|
factsArtifactName = factsArtifacts[0].name;
|
||||||
|
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||||
|
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||||
|
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||||
|
factsArtifactName = "";
|
||||||
|
} else {
|
||||||
|
artifactBaseSha = parsedBaseSha;
|
||||||
|
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||||
|
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prNumber) {
|
||||||
|
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
commit_sha: targetHeadSha,
|
||||||
|
});
|
||||||
|
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||||
|
candidate.state === "open" &&
|
||||||
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
|
candidate.head?.sha === targetHeadSha
|
||||||
|
);
|
||||||
|
if (candidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||||
|
}
|
||||||
|
if (candidatePRs.length === 1) {
|
||||||
|
prNumber = candidatePRs[0].number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prNumber) {
|
||||||
|
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: "open",
|
||||||
|
per_page: 100,
|
||||||
|
}).then((prs) => prs.filter((candidate) =>
|
||||||
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
|
candidate.head?.sha === targetHeadSha
|
||||||
|
));
|
||||||
|
if (candidatePRs.length !== 1) {
|
||||||
|
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||||
|
}
|
||||||
|
prNumber = candidatePRs[0].number;
|
||||||
|
}
|
||||||
|
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.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.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 });
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/lark-cli
|
/lark-cli*
|
||||||
.cache/
|
.cache/
|
||||||
dist/
|
dist/
|
||||||
bin/
|
bin/
|
||||||
@@ -35,6 +35,8 @@ tests/mail/reports/
|
|||||||
# Generated / test artifacts
|
# Generated / test artifacts
|
||||||
.hammer/
|
.hammer/
|
||||||
.lark-slides/
|
.lark-slides/
|
||||||
|
/notes/
|
||||||
|
/minutes/
|
||||||
internal/registry/meta_data.json
|
internal/registry/meta_data.json
|
||||||
cmd/api/download.bin
|
cmd/api/download.bin
|
||||||
app.log
|
app.log
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ linters:
|
|||||||
- unused # checks for unused constants, variables, functions and types
|
- unused # checks for unused constants, variables, functions and types
|
||||||
- depguard # blocks forbidden package imports
|
- depguard # blocks forbidden package imports
|
||||||
- forbidigo # forbids specific function calls
|
- 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:
|
# To enable later after fixing existing issues:
|
||||||
# - errcheck # checks for unchecked errors
|
# - errcheck # checks for unchecked errors
|
||||||
# - errname # checks that error types are named XxxError
|
# - errname # checks that error types are named XxxError
|
||||||
# - errorlint # checks error wrapping best practices
|
|
||||||
# - gosec # security-oriented linter
|
# - gosec # security-oriented linter
|
||||||
# - misspell # finds commonly misspelled English words
|
# - misspell # finds commonly misspelled English words
|
||||||
# - staticcheck # comprehensive static analysis
|
# - staticcheck # comprehensive static analysis
|
||||||
@@ -49,18 +49,49 @@ linters:
|
|||||||
- gocritic
|
- gocritic
|
||||||
- depguard
|
- depguard
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- path-except: (shortcuts/|internal/)
|
- 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:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- path: internal/vfs/
|
- path: internal/vfs/
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
# internal/gen build-time generators (standalone `package main` run via
|
||||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||||
|
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||||
|
# impossible here: a structured error return needs os.Exit (also banned),
|
||||||
|
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||||
|
- path: shortcuts/.*/internal/gen/
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
# internal/qualitygate/cmd contains standalone CI tools. Their main
|
||||||
|
# entrypoints legitimately own process exit codes and stdio, matching the
|
||||||
|
# old tools/ layout before these packages moved under internal/.
|
||||||
|
- path: internal/qualitygate/cmd/[^/]+/main\.go$
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||||
|
# for the client / credential layer.
|
||||||
- path-except: shortcuts/
|
- path-except: shortcuts/
|
||||||
text: shortcuts-no-raw-http
|
text: shortcuts-no-raw-http
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
# errs-no-bare-wrap enforced across every command/wire boundary by
|
||||||
|
# structural prefix, so any future business domain or command is covered
|
||||||
|
# without editing an allowlist. Genuine intermediate wraps inside these
|
||||||
|
# paths use //nolint:forbidigo with a reason.
|
||||||
|
- path-except: (cmd/|shortcuts/|events/)
|
||||||
|
text: errs-no-bare-wrap
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
depguard:
|
depguard:
|
||||||
@@ -79,6 +110,12 @@ linters:
|
|||||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
forbid:
|
||||||
|
# ── bare error wraps banned on fully-typed paths ──
|
||||||
|
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||||
|
msg: >-
|
||||||
|
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||||
|
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||||
|
//nolint:forbidigo with a reason.
|
||||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||||
# Bans request / client construction; constants (http.MethodPost,
|
# Bans request / client construction; constants (http.MethodPost,
|
||||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # Build (runs fetch_meta first)
|
make build # Build (runs fetch_meta first)
|
||||||
make unit-test # Required before PR (runs with -race)
|
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||||
make test # Full: vet + unit + integration
|
make test # Full: vet + unit + integration
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
|||||||
|
|
||||||
### Structured errors in commands
|
### Structured errors in commands
|
||||||
|
|
||||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||||
|
|
||||||
|
Picking a constructor:
|
||||||
|
|
||||||
|
| Failure | Constructor |
|
||||||
|
|---------|-------------|
|
||||||
|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||||
|
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||||
|
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||||
|
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||||
|
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||||
|
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||||
|
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||||
|
|
||||||
|
Signatures that are easy to guess wrong:
|
||||||
|
|
||||||
|
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||||
|
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||||
|
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||||
|
|
||||||
|
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||||
|
|
||||||
|
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||||
|
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||||
|
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||||
|
|
||||||
### stdout is data, stderr is everything else
|
### stdout is data, stderr is everything else
|
||||||
|
|
||||||
|
|||||||
393
CHANGELOG.md
393
CHANGELOG.md
@@ -2,6 +2,382 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.57] - 2026-06-23
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||||
|
- **base**: Support record comments (#1043)
|
||||||
|
- **search**: Surface search API notices (#1413)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||||
|
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||||
|
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||||
|
- **mail**: Trim lark-mail skill context (#1527)
|
||||||
|
- **drive**: Add permission governance workflow guidance (#1292)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||||
|
|
||||||
|
## [v1.0.56] - 2026-06-18
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Align API success envelopes (#1489)
|
||||||
|
- **base**: Reject out-of-range pagination flags (#1495)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Soften lark-doc style guidance (#1463)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- Add CI quality gate with semantic review
|
||||||
|
|
||||||
|
## [v1.0.55] - 2026-06-16
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **vc**: Support agent meeting event workflows (#1483)
|
||||||
|
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||||
|
- **doc**: Add docx cover resource commands (#1468)
|
||||||
|
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||||
|
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **drive**: Validate drive import folder target (#1485)
|
||||||
|
|
||||||
|
## [v1.0.54] - 2026-06-15
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||||
|
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||||
|
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||||
|
- **vfs**: Reject blank local paths (#1460)
|
||||||
|
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||||
|
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||||
|
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||||
|
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||||
|
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||||
|
|
||||||
|
## [v1.0.53] - 2026-06-12
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||||
|
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||||
|
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||||
|
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||||
|
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Optimize whiteboard skill (#1371)
|
||||||
|
- **skills**: Optimize okr skill (#1368)
|
||||||
|
|
||||||
|
## [v1.0.52] - 2026-06-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||||
|
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||||
|
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||||
|
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||||
|
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||||
|
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **apps**: Support git credential dry-run (#1390)
|
||||||
|
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||||
|
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||||
|
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||||
|
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||||
|
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||||
|
|
||||||
|
## [v1.0.51] - 2026-06-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **apps**: Support multi dev modes (#1175)
|
||||||
|
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||||
|
- **base**: Configure initial base table schema (#1377)
|
||||||
|
- **vc**: Add recording event support (#1369)
|
||||||
|
- **minutes**: Replace words for transcript (#1372)
|
||||||
|
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||||
|
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||||
|
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||||
|
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||||
|
|
||||||
|
## [v1.0.50] - 2026-06-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||||
|
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||||
|
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||||
|
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||||
|
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||||
|
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||||
|
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||||
|
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||||
|
- **drive**: Add drive comment location guidance (#1258)
|
||||||
|
|
||||||
|
## [v1.0.49] - 2026-06-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||||
|
- **im**: Support feed group (#1102)
|
||||||
|
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||||
|
- **im**: Format feed group error handling (#1308)
|
||||||
|
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||||
|
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||||
|
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||||
|
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||||
|
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||||
|
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||||
|
- **markdown**: Harden create upload failures (#1325)
|
||||||
|
- **drive**: Harden inspect shortcut failures (#1324)
|
||||||
|
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||||
|
- **doc**: Remove docs v1 API (#1291)
|
||||||
|
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||||
|
- **cli**: Fetch official skills index (#1301)
|
||||||
|
- **shared**: Document relative-path-only file arguments (#1319)
|
||||||
|
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||||
|
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||||
|
- **drive**: Use docs secure label read scope (#1281)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||||
|
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||||
|
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||||
|
- **markdown**: Add markdown domain template (#1293)
|
||||||
|
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||||
|
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||||
|
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||||
|
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||||
|
|
||||||
|
## [v1.0.48] - 2026-06-04
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||||
|
- **contact**: Add contact skill domain guidance (#1144)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **skills**: Use JSON skills list during update (#1251)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||||
|
- **vc-agent**: Require explicit leave request (#1260)
|
||||||
|
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||||
|
|
||||||
|
## [v1.0.47] - 2026-06-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||||
|
- **base**: Add base block shortcuts (#1044)
|
||||||
|
- **im**: Complete card message format (#1198)
|
||||||
|
- **im**: Improve markdown guidance for messages (#1237)
|
||||||
|
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||||
|
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||||
|
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||||
|
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||||
|
- **wiki**: Support `appid` member type (#1235)
|
||||||
|
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||||
|
- **config**: Validate credentials after `config init` (#1151)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||||
|
|
||||||
|
## [v1.0.46] - 2026-06-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **im**: Add card message format support (#1218)
|
||||||
|
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||||
|
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||||
|
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||||
|
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||||
|
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||||
|
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **base**: Optimize base skill references (#1171)
|
||||||
|
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||||
|
|
||||||
|
## [v1.0.45] - 2026-06-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
||||||
|
- **platform**: Support multiple policy rules per plugin (#1182)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
||||||
|
- **whiteboard**: Fix whiteboard skill (#1180)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Update login hint and split-flow docs (#1201)
|
||||||
|
|
||||||
|
## [v1.0.44] - 2026-05-29
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
|
||||||
|
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
|
||||||
|
- **agent**: Add agent header support (#1158)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
|
||||||
|
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
|
||||||
|
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
|
||||||
|
- **whiteboard**: Fix whiteboard skill (#1166)
|
||||||
|
|
||||||
|
## [v1.0.43] - 2026-05-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **event**: Support `note` generated event (#1159)
|
||||||
|
- **config**: Decouple `--lang` preference from TUI display language (#1132)
|
||||||
|
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
|
||||||
|
- **config**: Allow lark-channel bind source override (#1154)
|
||||||
|
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
|
||||||
|
- **base**: Include `log_id` in attachment media errors (#1133)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **im**: Update IM skill urgent APIs (#1153)
|
||||||
|
|
||||||
|
## [v1.0.42] - 2026-05-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
|
||||||
|
- **im**: Enrich messages with reactions and output `update_time` (#1095)
|
||||||
|
- **schema**: Output JSON spec envelope for all API commands (#1048)
|
||||||
|
- **event**: Support `vc` / `note` / `minute` events (#1113)
|
||||||
|
- **drive**: Add secure label shortcuts (#985)
|
||||||
|
- **affordance**: Use description and command in affordance example schema (#1126)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **docs**: Remove unsupported `fetch` text format (#1109)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
|
||||||
|
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
|
||||||
|
|
||||||
|
## [v1.0.41] - 2026-05-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **minutes**: Add minutes edit shortcuts (#1036)
|
||||||
|
- **minutes**: Get minutes keywords (#1079)
|
||||||
|
- **slides**: Support importing pptx as slides (#1068)
|
||||||
|
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
|
||||||
|
- **errors**: Add structured CLI error contract (#984)
|
||||||
|
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **drive**: Support doubao drive inspect URL variants (#1106)
|
||||||
|
- **skills**: Sync skills incrementally during update (#1042)
|
||||||
|
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
|
||||||
|
- **common**: Escape special chars in multipart form filenames (#1037)
|
||||||
|
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Fix agent routing for doubao.com URLs (#1082)
|
||||||
|
- **task**: Require `--complete=false` for pending standup summaries (#1101)
|
||||||
|
- **base**: Document UI-only field settings (#1078)
|
||||||
|
- **contributing**: Clarify contributor guidance (#1096)
|
||||||
|
|
||||||
## [v1.0.40] - 2026-05-25
|
## [v1.0.40] - 2026-05-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -860,6 +1236,23 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||||
|
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||||
|
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||||
|
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||||
|
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||||
|
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||||
|
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||||
|
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||||
|
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||||
|
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||||
|
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||||
|
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||||
|
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||||
|
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||||
|
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||||
|
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||||
|
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
|
||||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
||||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||||
|
|||||||
49
Makefile
49
Makefile
@@ -5,10 +5,24 @@ BINARY := lark-cli
|
|||||||
MODULE := github.com/larksuite/cli
|
MODULE := github.com/larksuite/cli
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
DATE := $(shell date +%Y-%m-%d)
|
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
|
||||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||||
|
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||||
|
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||||
|
# $(shell ...).
|
||||||
|
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||||
|
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||||
|
|
||||||
|
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||||
|
|
||||||
all: test
|
all: test
|
||||||
|
|
||||||
@@ -32,9 +46,15 @@ fmt-check:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
script-test:
|
||||||
|
bash scripts/resolve-changed-from.test.sh
|
||||||
|
bash scripts/ci-workflow.test.sh
|
||||||
|
bash scripts/semantic-review-workflow.test.sh
|
||||||
|
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
go test -race -gcflags="all=-N -l" -count=1 \
|
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||||
|
|
||||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||||
@@ -46,7 +66,30 @@ examples-build:
|
|||||||
integration-test: build
|
integration-test: build
|
||||||
go test -v -count=1 ./tests/...
|
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))
|
||||||
|
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) \
|
||||||
|
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
install -d $(PREFIX)/bin
|
install -d $(PREFIX)/bin
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
|||||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||||
|
|
||||||
## Installation & Quick Start
|
## Installation & Quick Start
|
||||||
|
|
||||||
@@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
|||||||
|
|
||||||
For major changes, we recommend discussing with us first via an Issue.
|
For major changes, we recommend discussing with us first via an Issue.
|
||||||
|
|
||||||
|
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the **MIT License**.
|
This project is licensed under the **MIT License**.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||||
|
|
||||||
## 安装与快速开始
|
## 安装与快速开始
|
||||||
|
|
||||||
@@ -280,6 +280,8 @@ lark-cli schema im.messages.delete
|
|||||||
|
|
||||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||||
|
|
||||||
|
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目基于 **MIT 许可证** 开源。
|
本项目基于 **MIT 许可证** 开源。
|
||||||
|
|||||||
105
cmd/api/api.go
105
cmd/api/api.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -90,6 +91,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
|||||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||||
|
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||||
@@ -122,7 +124,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
|||||||
|
|
||||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||||
if opts.Params == "-" && opts.Data == "-" {
|
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)
|
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||||
@@ -152,7 +160,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
|||||||
return client.RawApiRequest{}, nil, err
|
return client.RawApiRequest{}, nil, err
|
||||||
}
|
}
|
||||||
if _, ok := dataFields.(map[string]any); !ok {
|
if _, ok := dataFields.(map[string]any); !ok {
|
||||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--data must be a JSON object when used with --file").
|
||||||
|
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
|
||||||
|
WithParam("--data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +206,13 @@ func apiRun(opts *APIOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.PageAll && opts.Output != "" {
|
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 {
|
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -232,17 +249,17 @@ func apiRun(opts *APIOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.PageAll {
|
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})
|
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
|
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
||||||
// raw API error detail (log_id, troubleshooter, permission_violations)
|
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
||||||
// stays on the wire — `lark-cli api` callers explicitly want the raw
|
// through here keep their canonical message / hint from BuildAPIError;
|
||||||
// envelope.
|
// 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{
|
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
@@ -253,16 +270,16 @@ func apiRun(opts *APIOptions) error {
|
|||||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||||
CommandPath: opts.Cmd.CommandPath(),
|
CommandPath: opts.Cmd.CommandPath(),
|
||||||
Identity: opts.As,
|
Identity: opts.As,
|
||||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
||||||
// Per-domain migration in stage 2+ will route through
|
// codes (typed PermissionError / AuthenticationError / ...). For
|
||||||
// errclass.BuildAPIError to populate identity-aware fields
|
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
||||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
// the client populate identity-aware fields (ConsoleURL etc.).
|
||||||
CheckError: ac.CheckResponse,
|
CheckError: ac.CheckResponse,
|
||||||
})
|
})
|
||||||
// MarkRaw: see comment above on the DoAPI path. Applies equally to
|
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||||
// HandleResponse failures so the raw API error survives to the wire.
|
// *ExitError enrichment; typed errors flow through unchanged.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return errs.MarkRaw(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -271,46 +288,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
|||||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||||
if pagOpts.Identity == "" {
|
if pagOpts.Identity == "" {
|
||||||
pagOpts.Identity = request.As
|
pagOpts.Identity = request.As
|
||||||
}
|
}
|
||||||
// When jq is set, always aggregate all pages then filter.
|
// When jq is set, always aggregate all pages then filter.
|
||||||
if jqExpr != "" {
|
if jqExpr != "" {
|
||||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||||
return output.MarkRaw(err)
|
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 {
|
switch format {
|
||||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||||
pf := output.NewPaginatedFormatter(out, format)
|
pf := output.NewPaginatedFormatter(out, format)
|
||||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||||
|
// Streaming formats intentionally emit each page after that page has
|
||||||
|
// passed safety scanning. A later page may still fail, so callers
|
||||||
|
// must use the exit code to distinguish complete vs partial output.
|
||||||
|
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||||
|
if scanResult.Blocked {
|
||||||
|
return scanResult.BlockErr
|
||||||
|
}
|
||||||
|
if scanResult.Alert != nil {
|
||||||
|
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||||
|
}
|
||||||
pf.FormatPage(items)
|
pf.FormatPage(items)
|
||||||
|
return nil
|
||||||
}, pagOpts)
|
}, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return errs.MarkRaw(err)
|
||||||
}
|
}
|
||||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||||
output.FormatValue(out, result, output.FormatJSON)
|
return errs.MarkRaw(apiErr)
|
||||||
return output.MarkRaw(apiErr)
|
|
||||||
}
|
}
|
||||||
if !hasItems {
|
if !hasItems {
|
||||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
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
|
return nil
|
||||||
default:
|
default:
|
||||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return errs.MarkRaw(err)
|
||||||
}
|
}
|
||||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||||
output.FormatValue(out, result, output.FormatJSON)
|
output.FormatValue(out, result, output.FormatJSON)
|
||||||
return output.MarkRaw(apiErr)
|
return errs.MarkRaw(apiErr)
|
||||||
}
|
}
|
||||||
output.FormatValue(out, result, format)
|
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||||
return nil
|
CommandPath: commandPath,
|
||||||
|
Identity: string(pagOpts.Identity),
|
||||||
|
Out: out,
|
||||||
|
ErrOut: errOut,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -64,6 +69,24 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||||
|
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||||
|
// write into the map ParseJSONMap returns.
|
||||||
|
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||||
|
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiCmd_BotMode(t *testing.T) {
|
func TestApiCmd_BotMode(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -81,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "success") {
|
var got map[string]interface{}
|
||||||
t.Error("expected 'success' in output")
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if got["ok"] != true || got["identity"] != "bot" {
|
||||||
|
t.Fatalf("unexpected envelope: %#v", got)
|
||||||
|
}
|
||||||
|
if _, hasCode := got["code"]; hasCode {
|
||||||
|
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||||
|
}
|
||||||
|
data, ok := got["data"].(map[string]interface{})
|
||||||
|
if !ok || data["result"] != "success" {
|
||||||
|
t.Fatalf("data = %#v, want result=success", got["data"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|||||||
t.Error("expected 'falling back to json' in stderr")
|
t.Error("expected 'falling back to json' in stderr")
|
||||||
}
|
}
|
||||||
// Should output JSON result to stdout
|
// Should output JSON result to stdout
|
||||||
if !strings.Contains(stdout.String(), "u123") {
|
var got map[string]interface{}
|
||||||
t.Error("expected user_id in JSON output")
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
data, ok := got["data"].(map[string]interface{})
|
||||||
|
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
|
||||||
|
t.Fatalf("unexpected fallback envelope: %#v", got)
|
||||||
|
}
|
||||||
|
if _, hasCode := got["code"]; hasCode {
|
||||||
|
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 230001, "msg": "no permission",
|
"code": 230027, "msg": "user not authorized",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -334,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
t.Fatal("expected an error for non-zero code")
|
t.Fatal("expected an error for non-zero code")
|
||||||
}
|
}
|
||||||
// Should still output the response body so user can see the error details
|
// Should still output the response body so user can see the error details
|
||||||
if !strings.Contains(stdout.String(), "230001") {
|
if !strings.Contains(stdout.String(), "230027") {
|
||||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "no permission") {
|
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||||
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
||||||
}
|
}
|
||||||
|
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||||
|
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||||
|
}
|
||||||
|
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||||
|
var permErr *errs.PermissionError
|
||||||
|
if !errors.As(err, &permErr) {
|
||||||
|
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||||
@@ -375,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||||
|
"has_more": true,
|
||||||
|
"page_token": "next",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 230027, "msg": "user not authorized",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero code on later page")
|
||||||
|
}
|
||||||
|
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "safe-page") {
|
||||||
|
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
|
||||||
|
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "\n \"code\"") {
|
||||||
|
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
data, ok := got["data"].(map[string]interface{})
|
||||||
|
if got["ok"] != true || got["identity"] != "bot" || !ok {
|
||||||
|
t.Fatalf("unexpected envelope: %#v", got)
|
||||||
|
}
|
||||||
|
if _, hasCode := got["code"]; hasCode {
|
||||||
|
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||||
|
}
|
||||||
|
items, ok := data["items"].([]interface{})
|
||||||
|
if !ok || len(items) != 1 {
|
||||||
|
t.Fatalf("data.items = %#v, want one item", data["items"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiContentSafetyProvider struct {
|
||||||
|
called bool
|
||||||
|
path string
|
||||||
|
data interface{}
|
||||||
|
match string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
|
||||||
|
|
||||||
|
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||||
|
p.called = true
|
||||||
|
p.path = req.Path
|
||||||
|
p.data = req.Data
|
||||||
|
if p.match != "" {
|
||||||
|
b, _ := json.Marshal(req.Data)
|
||||||
|
if !strings.Contains(string(b), p.match) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||||
|
provider := &apiContentSafetyProvider{}
|
||||||
|
extcs.Register(provider)
|
||||||
|
t.Cleanup(func() { extcs.Register(nil) })
|
||||||
|
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !provider.called {
|
||||||
|
t.Fatal("expected content safety provider to scan paginated output")
|
||||||
|
}
|
||||||
|
if provider.path != "api" {
|
||||||
|
t.Fatalf("scan path = %q, want api", provider.path)
|
||||||
|
}
|
||||||
|
data, ok := provider.data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||||
|
}
|
||||||
|
if _, hasCode := data["code"]; hasCode {
|
||||||
|
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
alert, ok := got["_content_safety_alert"].(map[string]interface{})
|
||||||
|
if !ok || alert["provider"] != "api-test" {
|
||||||
|
t.Fatalf("missing content safety alert in envelope: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||||
|
provider := &apiContentSafetyProvider{}
|
||||||
|
extcs.Register(provider)
|
||||||
|
t.Cleanup(func() { extcs.Register(nil) })
|
||||||
|
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !provider.called {
|
||||||
|
t.Fatal("expected content safety provider to scan streamed paginated output")
|
||||||
|
}
|
||||||
|
if provider.path != "api" {
|
||||||
|
t.Fatalf("scan path = %q, want api", provider.path)
|
||||||
|
}
|
||||||
|
items, ok := provider.data.([]interface{})
|
||||||
|
if !ok || len(items) != 1 {
|
||||||
|
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
|
||||||
|
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"id":"1"`) {
|
||||||
|
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||||
|
provider := &apiContentSafetyProvider{match: "blocked"}
|
||||||
|
extcs.Register(provider)
|
||||||
|
t.Cleanup(func() { extcs.Register(nil) })
|
||||||
|
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||||
|
"has_more": true,
|
||||||
|
"page_token": "next",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/contact/v3/users",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
|
err := root.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected content safety block error")
|
||||||
|
}
|
||||||
|
var safetyErr *errs.ContentSafetyError
|
||||||
|
if !errors.As(err, &safetyErr) {
|
||||||
|
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||||
|
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||||
|
}
|
||||||
|
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
|
||||||
|
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "safe-page") {
|
||||||
|
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "blocked-page") {
|
||||||
|
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
|
||||||
|
t.Helper()
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != category || p.Subtype != subtype || p.Code != code {
|
||||||
|
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -670,3 +988,69 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
t.Errorf("expected dry-run header, got: %s", out)
|
t.Errorf("expected dry-run header, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
|
||||||
|
// API returns a missing-scope failure, the typed *errs.PermissionError
|
||||||
|
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
|
||||||
|
// consumed during classification into first-class wire fields
|
||||||
|
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
|
||||||
|
// — there is no raw-payload passthrough; new Lark diagnostic fields require
|
||||||
|
// a CLI release.
|
||||||
|
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/docx/v1/documents/test",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991679,
|
||||||
|
"msg": "scope missing",
|
||||||
|
"log_id": "20260527-test-log",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "docx:document"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero code")
|
||||||
|
}
|
||||||
|
|
||||||
|
var pe *errs.PermissionError
|
||||||
|
if !errors.As(err, &pe) {
|
||||||
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
|
||||||
|
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
|
||||||
|
}
|
||||||
|
if pe.LogID != "20260527-test-log" {
|
||||||
|
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *APIOptions
|
||||||
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts.Method != "GET" {
|
||||||
|
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewCmdAuth creates the auth command with subcommands.
|
// NewCmdAuth creates the auth command with subcommands.
|
||||||
@@ -70,7 +71,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
|
|||||||
|
|
||||||
var resp userInfoResponse
|
var resp userInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to parse user info: %v", err)
|
return "", "", fmt.Errorf("failed to parse user info: %w", err)
|
||||||
}
|
}
|
||||||
if resp.Code != 0 {
|
if resp.Code != 0 {
|
||||||
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
||||||
@@ -110,6 +111,11 @@ type appInfoResponse struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
|
||||||
|
// can substitute a fake without standing up a full SDK + httpmock pipeline.
|
||||||
|
// Mirrors the pollDeviceToken pattern in login.go.
|
||||||
|
var getAppInfoFn = getAppInfo
|
||||||
|
|
||||||
// getAppInfo queries app info from the Lark API.
|
// getAppInfo queries app info from the Lark API.
|
||||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||||
ac, err := f.NewAPIClient()
|
ac, err := f.NewAPIClient()
|
||||||
@@ -131,10 +137,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
|||||||
|
|
||||||
var resp appInfoResponse
|
var resp appInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
if resp.Code != 0 {
|
if resp.Code != 0 {
|
||||||
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
|
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := resp.Data.App
|
app := resp.Data.App
|
||||||
@@ -153,3 +159,21 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
|||||||
|
|
||||||
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
|
||||||
|
// upstream `error` block — the typed appInfoResponse shape drops it.
|
||||||
|
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
|
||||||
|
var raw map[string]any
|
||||||
|
_ = json.Unmarshal(rawBody, &raw)
|
||||||
|
if raw == nil {
|
||||||
|
raw = map[string]any{}
|
||||||
|
}
|
||||||
|
raw["code"] = code
|
||||||
|
raw["msg"] = msg
|
||||||
|
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
||||||
|
if cfg, _ := f.Config(); cfg != nil {
|
||||||
|
cc.Brand = string(cfg.Brand)
|
||||||
|
cc.AppID = appId
|
||||||
|
}
|
||||||
|
return errclass.BuildAPIError(raw, cc)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -90,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *CheckOptions
|
||||||
|
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
@@ -108,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
var gotOpts *LogoutOptions
|
||||||
|
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
@@ -125,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
var gotOpts *ListOptions
|
||||||
|
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Error("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -144,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *StatusOptions
|
||||||
|
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Error("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -266,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *ScopesOptions
|
||||||
|
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
if gotOpts.Format != "json" {
|
||||||
|
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||||
@@ -318,6 +433,54 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
|
||||||
|
// the Lark API returns a permission code (99991679 with permission_violations),
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
tokenResolver := &authScopesTokenResolver{}
|
||||||
|
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/open-apis/application/v6/applications/test-app",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991679,
|
||||||
|
"msg": "scope missing",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "application:application:self_manage"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := authScopesRun(&ScopesOptions{
|
||||||
|
Factory: f,
|
||||||
|
Ctx: context.Background(),
|
||||||
|
Format: "json",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var pe *errs.PermissionError
|
||||||
|
if !errors.As(err, &pe) {
|
||||||
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
|
||||||
|
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var intErr *errs.InternalError
|
||||||
|
if errors.As(err, &intErr) {
|
||||||
|
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type authScopesTokenResolver struct {
|
type authScopesTokenResolver struct {
|
||||||
requests []credential.TokenSpec
|
requests []credential.TokenSpec
|
||||||
}
|
}
|
||||||
@@ -389,15 +552,8 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
|
||||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
type CheckOptions struct {
|
type CheckOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Scope string
|
Scope string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthCheck creates the auth check subcommand.
|
// NewCmdAuthCheck creates the auth check subcommand.
|
||||||
@@ -36,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmd.MarkFlagRequired("scope")
|
cmd.MarkFlagRequired("scope")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ func authCheckRun(opts *CheckOptions) error {
|
|||||||
|
|
||||||
required := strings.Fields(opts.Scope)
|
required := strings.Fields(opts.Scope)
|
||||||
if len(required) == 0 {
|
if len(required) == 0 {
|
||||||
return output.ErrValidation("--scope cannot be empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := f.Config()
|
config, err := f.Config()
|
||||||
|
|||||||
164
cmd/auth/check_test.go
Normal file
164
cmd/auth/check_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `lark-cli auth check` is a predicate command: its README contract is
|
||||||
|
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
|
||||||
|
// empty so callers can write `if lark-cli auth check ...; then ... fi`
|
||||||
|
// without their logs getting polluted by an error envelope on the negative
|
||||||
|
// 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,
|
||||||
|
// UserOpenId left empty: triggers the not_logged_in branch.
|
||||||
|
})
|
||||||
|
|
||||||
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||||
|
|
||||||
|
if got := output.ExitCodeOf(err); got != 1 {
|
||||||
|
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
||||||
|
}
|
||||||
|
var bare *output.BareError
|
||||||
|
if !errors.As(err, &bare) {
|
||||||
|
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != false {
|
||||||
|
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["error"] != "not_logged_in" {
|
||||||
|
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
UserOpenId: "ou_user", UserName: "tester",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||||
|
|
||||||
|
if got := output.ExitCodeOf(err); got != 1 {
|
||||||
|
t.Errorf("exit code = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if payload["ok"] != false {
|
||||||
|
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["error"] != "no_token" {
|
||||||
|
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||||
|
// Predicate command happy path: stored token covers every required
|
||||||
|
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
|
||||||
|
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
|
||||||
|
// callers can rely on `if lark-cli auth check ...; then` without log
|
||||||
|
// pollution. Pairs with the two exit-1 negatives above so both
|
||||||
|
// branches of the predicate contract are pinned.
|
||||||
|
keyring.MockInit()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
cfg := &core.CliConfig{
|
||||||
|
AppID: "test-app",
|
||||||
|
AppSecret: "test-secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
UserName: "tester",
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: cfg.AppID,
|
||||||
|
UserOpenId: cfg.UserOpenId,
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
RefreshToken: "refresh-token",
|
||||||
|
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||||
|
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||||
|
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||||
|
Scope: "im:message docx:document",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||||
|
|
||||||
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
|
||||||
|
}
|
||||||
|
if got := output.ExitCodeOf(err); got != 0 {
|
||||||
|
t.Errorf("exit code = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
granted, ok := payload["granted"].([]any)
|
||||||
|
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
||||||
|
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
|
||||||
|
}
|
||||||
|
if payload["missing"] != nil {
|
||||||
|
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
|
||||||
|
}
|
||||||
|
if _, has := payload["suggestion"]; has {
|
||||||
|
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error for empty --scope")
|
||||||
|
}
|
||||||
|
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||||
|
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
// ListOptions holds all inputs for auth list.
|
// ListOptions holds all inputs for auth list.
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthList creates the auth list subcommand.
|
// NewCmdAuthList creates the auth list subcommand.
|
||||||
@@ -34,6 +36,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
|||||||
return authListRun(opts)
|
return authListRun(opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -44,12 +47,20 @@ func authListRun(opts *ListOptions) error {
|
|||||||
|
|
||||||
multi, _ := core.LoadMultiAppConfig()
|
multi, _ := core.LoadMultiAppConfig()
|
||||||
if multi == nil || len(multi.Apps) == 0 {
|
if multi == nil || len(multi.Apps) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"users": []map[string]interface{}{},
|
||||||
|
"reason": "not_configured",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// auth list is a read-only probe; the "configured but no users"
|
// auth list is a read-only probe; the "configured but no users"
|
||||||
// branch below already returns exit 0 with a stderr hint, so we
|
// branch below already returns exit 0 with a stderr hint, so we
|
||||||
// keep the same contract here. We still want the hint to be
|
// keep the same contract here. We still want the hint to be
|
||||||
// workspace-aware, so we pull the message+hint out of
|
// workspace-aware, so we pull the message+hint out of
|
||||||
// NotConfiguredError() instead of hard-coding it.
|
// NotConfiguredError() instead of hard-coding it.
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||||
if cfgErr.Hint != "" {
|
if cfgErr.Hint != "" {
|
||||||
@@ -61,6 +72,14 @@ func authListRun(opts *ListOptions) error {
|
|||||||
|
|
||||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil || len(app.Users) == 0 {
|
if app == nil || len(app.Users) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"users": []map[string]interface{}{},
|
||||||
|
"reason": "not_logged_in",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
users, ok := payload["users"].([]any)
|
||||||
|
if !ok || len(users) != 0 {
|
||||||
|
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_configured" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
|||||||
t.Errorf("agent hint must not mention config init: %s", out)
|
t.Errorf("agent hint must not mention config init: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
users, ok := payload["users"].([]any)
|
||||||
|
if !ok || len(users) != 0 {
|
||||||
|
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_logged_in" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||||
|
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
@@ -53,9 +56,9 @@ run --device-code in a later step after the user confirms authorization. Use 'la
|
|||||||
to generate QR codes (supports ASCII and PNG formats).`,
|
to generate QR codes (supports ASCII and PNG formats).`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
"strict mode is %q, user login is disabled in this profile", mode).
|
||||||
"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)")
|
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)")
|
||||||
}
|
}
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
@@ -121,7 +124,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine UI language from saved config
|
// Determine UI language from saved config
|
||||||
lang := "zh"
|
var lang i18n.Lang
|
||||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||||
lang = app.Lang
|
lang = app.Lang
|
||||||
@@ -157,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
for _, d := range selectedDomains {
|
for _, d := range selectedDomains {
|
||||||
if !knownDomains[d] {
|
if !knownDomains[d] {
|
||||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||||
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
||||||
}
|
}
|
||||||
available := make([]string, 0, len(knownDomains))
|
available := make([]string, 0, len(knownDomains))
|
||||||
for k := range knownDomains {
|
for k := range knownDomains {
|
||||||
available = append(available, k)
|
available = append(available, k)
|
||||||
}
|
}
|
||||||
sort.Strings(available)
|
sort.Strings(available)
|
||||||
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,17 +175,17 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||||
|
|
||||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasAnyOption {
|
if !hasAnyOption {
|
||||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
|
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return output.ErrValidation("no login options selected")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
||||||
}
|
}
|
||||||
selectedDomains = result.Domains
|
selectedDomains = result.Domains
|
||||||
scopeLevel = result.ScopeLevel
|
scopeLevel = result.ScopeLevel
|
||||||
@@ -198,7 +201,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
log(msg.HintFooter)
|
log(msg.HintFooter)
|
||||||
log("")
|
log("")
|
||||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||||
return output.ErrValidation("please specify the scopes to authorize")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +230,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge --scope additively with the resolved domain scopes.
|
// Merge --scope additively with the resolved domain scopes.
|
||||||
@@ -247,13 +250,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
if len(opts.Exclude) > 0 {
|
if len(opts.Exclude) > 0 {
|
||||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||||
if len(unknown) > 0 {
|
if len(unknown) > 0 {
|
||||||
return output.ErrValidation(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"these --exclude scopes are not present in the requested set: %s",
|
"these --exclude scopes are not present in the requested set: %s",
|
||||||
strings.Join(unknown, ", "))
|
strings.Join(unknown, ", ")).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
finalScope = excluded
|
finalScope = excluded
|
||||||
if strings.TrimSpace(finalScope) == "" {
|
if strings.TrimSpace(finalScope) == "" {
|
||||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +267,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrAuth("device authorization failed: %v", err)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --no-wait: return immediately with device code and URL
|
// --no-wait: return immediately with device code and URL
|
||||||
@@ -276,21 +279,28 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"verification_url": authResp.VerificationUriComplete,
|
"verification_url": authResp.VerificationUriComplete,
|
||||||
"device_code": authResp.DeviceCode,
|
"device_code": authResp.DeviceCode,
|
||||||
"expires_in": authResp.ExpiresIn,
|
"expires_in": authResp.ExpiresIn,
|
||||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||||
|
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||||
|
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||||
|
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||||
|
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||||
|
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||||
|
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||||
}
|
}
|
||||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
if err := encoder.Encode(data); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Show user code and verification URL.
|
// Step 2: Show user code and verification URL.
|
||||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||||
// JSON mode embeds it as a structured field (so an agent that captures
|
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||||
// text mode prints to stderr (alongside the URL prompt).
|
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||||
|
// the agent-oriented instructions.
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"event": "device_authorization",
|
"event": "device_authorization",
|
||||||
@@ -303,12 +313,14 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
if err := encoder.Encode(data); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||||
|
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Poll for token
|
// Step 3: Poll for token
|
||||||
@@ -324,25 +336,25 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"event": "authorization_failed",
|
"event": "authorization_failed",
|
||||||
"error": result.Message,
|
"error": result.Message,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAuth)
|
return output.ErrBare(output.ExitAuth)
|
||||||
}
|
}
|
||||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||||
}
|
}
|
||||||
if result.Token == nil {
|
if result.Token == nil {
|
||||||
return output.ErrAuth("authorization succeeded but no token returned")
|
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Get user info
|
// Step 6: Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrAuth("failed to get SDK: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrAuth("failed to get user info: %v", err)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||||
@@ -360,13 +372,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||||
@@ -395,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||||
// device_code already returned the hint as a JSON field, and writing
|
// the device_code already surfaced it as a JSON field), and also skip it
|
||||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
// when running on an interactive terminal — the agent-oriented
|
||||||
if !opts.JSON {
|
// instructions only matter for piped / harness environments.
|
||||||
|
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||||
}
|
}
|
||||||
log(msg.WaitingAuth)
|
log(msg.WaitingAuth)
|
||||||
@@ -409,22 +422,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
if shouldRemoveLoginRequestedScope(result) {
|
if shouldRemoveLoginRequestedScope(result) {
|
||||||
cleanupRequestedScope()
|
cleanupRequestedScope()
|
||||||
}
|
}
|
||||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||||
}
|
}
|
||||||
defer cleanupRequestedScope()
|
defer cleanupRequestedScope()
|
||||||
if result.Token == nil {
|
if result.Token == nil {
|
||||||
return output.ErrAuth("authorization succeeded but no token returned")
|
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrAuth("failed to get SDK: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrAuth("failed to get user info: %v", err)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||||
@@ -442,13 +455,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config — overwrite Users to single user, clean old tokens
|
// Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||||
@@ -463,18 +476,18 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||||
multi, err := core.LoadMultiAppConfig()
|
multi, err := core.LoadMultiAppConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load config: %w", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := findProfileByName(multi, profileName)
|
app := findProfileByName(multi, profileName)
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return fmt.Errorf("profile %q not found in config", profileName)
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return fmt.Errorf("save config: %w", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, oldUser := range oldUsers {
|
for _, oldUser := range oldUsers {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -91,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
|
|||||||
Description: desc,
|
Description: desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: read from from_meta spec (legacy)
|
// Fallback: read from the typed service spec (legacy)
|
||||||
meta := registry.LoadFromMeta(name)
|
|
||||||
dm := domainMeta{Name: name}
|
dm := domainMeta{Name: name}
|
||||||
if meta != nil {
|
if svc, ok := registry.ServiceTyped(name); ok {
|
||||||
if t, ok := meta["title"].(string); ok {
|
dm.Title = svc.Title
|
||||||
dm.Title = t
|
dm.Description = svc.Description
|
||||||
}
|
|
||||||
if d, ok := meta["description"].(string); ok {
|
|
||||||
dm.Description = d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return dm
|
return dm
|
||||||
}
|
}
|
||||||
@@ -162,7 +158,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(selectedDomains) == 0 {
|
if len(selectedDomains) == 0 {
|
||||||
return nil, output.ErrValidation("no domains selected")
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute scope summary
|
// Compute scope summary
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/internal/i18n"
|
||||||
|
|
||||||
type loginMsg struct {
|
type loginMsg struct {
|
||||||
// Interactive UI (login_interactive.go)
|
// Interactive UI (login_interactive.go)
|
||||||
SelectDomains string
|
SelectDomains string
|
||||||
@@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getLoginMsg returns the login message bundle for the given language.
|
// getLoginMsg returns the login message bundle for the given language.
|
||||||
func getLoginMsg(lang string) *loginMsg {
|
func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||||
if lang == "en" {
|
if lang.IsEnglish() {
|
||||||
return loginMsgEn
|
return loginMsgEn
|
||||||
}
|
}
|
||||||
return loginMsgZh
|
return loginMsgZh
|
||||||
@@ -126,5 +128,5 @@ func getLoginMsg(lang string) *loginMsg {
|
|||||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||||
// service_descriptions.json.
|
// service_descriptions.json.
|
||||||
func getShortcutOnlyDomainNames() []string {
|
func getShortcutOnlyDomainNames() []string {
|
||||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetLoginMsg_Zh(t *testing.T) {
|
func TestGetLoginMsg_Zh(t *testing.T) {
|
||||||
@@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
||||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
|
||||||
msg := getLoginMsg(lang)
|
msg := getLoginMsg(lang)
|
||||||
if msg != loginMsgZh {
|
if msg != loginMsgZh {
|
||||||
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
||||||
@@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginMsg_FormatStrings(t *testing.T) {
|
func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||||
for _, lang := range []string{"zh", "en"} {
|
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||||
msg := getLoginMsg(lang)
|
msg := getLoginMsg(lang)
|
||||||
|
|
||||||
// LoginSuccess should contain two %s placeholders (userName, openId)
|
// LoginSuccess should contain two %s placeholders (userName, openId)
|
||||||
@@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
|||||||
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
||||||
// after presenting the URL instead of blocking in the same turn.
|
// after presenting the URL instead of blocking in the same turn.
|
||||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||||
for _, lang := range []string{"zh", "en"} {
|
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||||
if lang == "zh" && want == "turn" {
|
if lang == i18n.LangZhCN && want == "turn" {
|
||||||
want = "本轮"
|
want = "本轮"
|
||||||
}
|
}
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -171,25 +172,12 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
|||||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||||
return output.ErrBare(output.ExitAuth)
|
return output.ErrBare(output.ExitAuth)
|
||||||
}
|
}
|
||||||
detail := map[string]interface{}{
|
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
|
||||||
"requested": issue.Summary.Requested,
|
WithHint("%s", issue.Hint).
|
||||||
"granted": issue.Summary.Granted,
|
WithIdentity("user").
|
||||||
"missing": issue.Summary.Missing,
|
WithRequestedScopes(issue.Summary.Requested...).
|
||||||
}
|
WithGrantedScopes(issue.Summary.Granted...).
|
||||||
// Legacy *output.ExitError producer: this literal predates the typed
|
WithMissingScopes(issue.Summary.Missing...)
|
||||||
// error contract introduced by errs/. New code MUST NOT construct
|
|
||||||
// *output.ExitError directly — missing-scope signals should move to
|
|
||||||
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
|
|
||||||
// extension fields) when the login flow migrates to typed errors.
|
|
||||||
return &output.ExitError{
|
|
||||||
Code: output.ExitAuth,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: "missing_scope",
|
|
||||||
Message: issue.Message,
|
|
||||||
Hint: issue.Hint,
|
|
||||||
Detail: detail,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||||
|
|||||||
61
cmd/auth/login_result_test.go
Normal file
61
cmd/auth/login_result_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
|
||||||
|
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
|
||||||
|
// requested + granted + missing scopes into the typed *PermissionError
|
||||||
|
// envelope. Consumers need the full triple to render actionable diagnostics,
|
||||||
|
// not just the missing set.
|
||||||
|
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
requested := []string{"docx:document", "im:message:send"}
|
||||||
|
granted := []string{"docx:document"}
|
||||||
|
missing := []string{"im:message:send"}
|
||||||
|
|
||||||
|
err := handleLoginScopeIssue(
|
||||||
|
&LoginOptions{JSON: true},
|
||||||
|
getLoginMsg("en"),
|
||||||
|
f,
|
||||||
|
&loginScopeIssue{
|
||||||
|
Message: "scope insufficient",
|
||||||
|
Hint: "re-login with --scope im:message:send",
|
||||||
|
Summary: &loginScopeSummary{
|
||||||
|
Requested: requested,
|
||||||
|
Granted: granted,
|
||||||
|
Missing: missing,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"", // openId empty -> loginSucceeded = false
|
||||||
|
"tester",
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
var permErr *errs.PermissionError
|
||||||
|
if !errors.As(err, &permErr) {
|
||||||
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
|
||||||
|
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
|
||||||
|
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
|
||||||
|
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||||
|
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||||
|
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectScopesForDomains(t *testing.T) {
|
func TestCollectScopesForDomains(t *testing.T) {
|
||||||
projects := registry.ListFromMetaProjects()
|
projects := registry.ListFromMetaProjects()
|
||||||
if len(projects) == 0 {
|
if len(projects) == 0 {
|
||||||
@@ -253,6 +260,15 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectScopesForDomains_SlidesDoesNotAdvertiseScreenshotScope(t *testing.T) {
|
||||||
|
scopes := collectScopesForDomains([]string{"slides"}, "user", "")
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "slides:presentation:screenshot" {
|
||||||
|
t.Fatalf("slides domain scopes must not advertise allowlist-gated screenshot scope: %#v", scopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
||||||
domains := getDomainMetadata("zh")
|
domains := getDomainMetadata("zh")
|
||||||
nameSet := make(map[string]bool)
|
nameSet := make(map[string]bool)
|
||||||
@@ -400,12 +416,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
@@ -443,12 +458,11 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]interface{}
|
var data map[string]interface{}
|
||||||
@@ -653,12 +667,11 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
|||||||
Ctx: context.Background(),
|
Ctx: context.Background(),
|
||||||
Scope: "im:message:send",
|
Scope: "im:message:send",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
@@ -870,6 +883,87 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
|
||||||
|
// contract that when --json is set and pollDeviceToken returns OK=false,
|
||||||
|
// stdout carries the structured authorization_failed event and stderr is
|
||||||
|
// NOT polluted with a typed envelope. The returned error is a bare
|
||||||
|
// BareError with ExitAuth so the dispatcher only propagates the exit code
|
||||||
|
// without emitting a second envelope on top of the JSON event.
|
||||||
|
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(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 {
|
||||||
|
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathDeviceAuthorization,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"device_code": "device-code",
|
||||||
|
"user_code": "user-code",
|
||||||
|
"verification_uri": "https://example.com/verify",
|
||||||
|
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||||
|
"expires_in": 240,
|
||||||
|
"interval": 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := authLoginRun(&LoginOptions{
|
||||||
|
Factory: f,
|
||||||
|
Ctx: context.Background(),
|
||||||
|
Scope: "im:message:send",
|
||||||
|
JSON: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for aborted authorization")
|
||||||
|
}
|
||||||
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdout: device_authorization event + authorization_failed event,
|
||||||
|
// the latter carrying the abort message as a structured field.
|
||||||
|
stdoutStr := stdout.String()
|
||||||
|
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
|
||||||
|
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdoutStr, "user denied") {
|
||||||
|
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
|
||||||
|
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
|
||||||
|
// log line goes through the JSON-mode no-op `log` helper so it is also
|
||||||
|
// suppressed in JSON mode.
|
||||||
|
stderrStr := stderr.String()
|
||||||
|
if strings.Contains(stderrStr, `"type":"authentication"`) {
|
||||||
|
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderrStr, `"error"`) {
|
||||||
|
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returned error must be the bare *output.BareError signal (no envelope).
|
||||||
|
var bareErr *output.BareError
|
||||||
|
if !errors.As(err, &bareErr) {
|
||||||
|
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if bareErr.Code != output.ExitAuth {
|
||||||
|
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
ProfileName: "default",
|
ProfileName: "default",
|
||||||
@@ -961,8 +1055,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
|||||||
"final message of the turn",
|
"final message of the turn",
|
||||||
"return control to the user",
|
"return control to the user",
|
||||||
"do not block on --device-code in the same turn",
|
"do not block on --device-code in the same turn",
|
||||||
"After the user confirms authorization in a later step",
|
"come back and notify",
|
||||||
"lark-cli auth login --device-code device-code",
|
"YOU must execute",
|
||||||
|
"lark-cli auth login --device-code <device_code>",
|
||||||
|
"Do NOT cache",
|
||||||
|
"lark-cli auth login --no-wait --json",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
// LogoutOptions holds all inputs for auth logout.
|
// LogoutOptions holds all inputs for auth logout.
|
||||||
type LogoutOptions struct {
|
type LogoutOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||||
@@ -33,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
|||||||
return authLogoutRun(opts)
|
return authLogoutRun(opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -43,24 +46,64 @@ func authLogoutRun(opts *LogoutOptions) error {
|
|||||||
|
|
||||||
multi, _ := core.LoadMultiAppConfig()
|
multi, _ := core.LoadMultiAppConfig()
|
||||||
if multi == nil || len(multi.Apps) == 0 {
|
if multi == nil || len(multi.Apps) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": false,
|
||||||
|
"reason": "not_configured",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil || len(app.Users) == 0 {
|
if app == nil || len(app.Users) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": false,
|
||||||
|
"reason": "not_logged_in",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpClient, httpErr := f.HttpClient()
|
||||||
|
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||||
|
|
||||||
for _, user := range app.Users {
|
for _, user := range app.Users {
|
||||||
|
if httpErr == nil && secretErr == nil {
|
||||||
|
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||||
|
revokeToken := token.RefreshToken
|
||||||
|
tokenTypeHint := "refresh_token"
|
||||||
|
if revokeToken == "" {
|
||||||
|
revokeToken = token.AccessToken
|
||||||
|
tokenTypeHint = "access_token"
|
||||||
|
}
|
||||||
|
if revokeToken != "" {
|
||||||
|
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Users = []core.AppUser{}
|
app.Users = []core.AppUser{}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": true,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||||
|
t.Helper()
|
||||||
|
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||||
|
CurrentApp: "test-app",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
AppId: "test-app",
|
||||||
|
AppSecret: core.PlainSecret("test-secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: users,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != false {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_configured" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != false {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_logged_in" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "test-app",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != true {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if _, hasReason := payload["reason"]; hasReason {
|
||||||
|
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "test-app",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "Logged out") {
|
||||||
|
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
RefreshToken: "user-refresh-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{"code": 0},
|
||||||
|
BodyFilter: func(body []byte) bool {
|
||||||
|
values, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values.Get("client_id") == "cli_test" &&
|
||||||
|
values.Get("client_secret") == "secret" &&
|
||||||
|
values.Get("token") == "user-refresh-token" &&
|
||||||
|
values.Get("token_type_hint") == "refresh_token"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", got)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{"code": 0},
|
||||||
|
BodyFilter: func(body []byte) bool {
|
||||||
|
values, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values.Get("client_id") == "cli_test" &&
|
||||||
|
values.Get("client_secret") == "secret" &&
|
||||||
|
values.Get("token") == "user-access-token" &&
|
||||||
|
values.Get("token_type_hint") == "access_token"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", got)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
RefreshToken: "user-refresh-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Status: 500,
|
||||||
|
Body: map[string]interface{}{"error": "server_error"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotErr := stderr.String()
|
||||||
|
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||||
|
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotErr, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -63,7 +63,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
|
|||||||
// runQRCode executes the auth qrcode command.
|
// runQRCode executes the auth qrcode command.
|
||||||
func runQRCode(opts *QRCodeOptions) error {
|
func runQRCode(opts *QRCodeOptions) error {
|
||||||
if opts.URL == "" {
|
if opts.URL == "" {
|
||||||
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.ASCII {
|
if opts.ASCII {
|
||||||
@@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Output == "" {
|
if opts.Output == "" {
|
||||||
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Size < 32 {
|
if opts.Size < 32 {
|
||||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Size > 1024 {
|
if opts.Size > 1024 {
|
||||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
|
||||||
}
|
}
|
||||||
|
|
||||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("unsafe output path: %s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||||
@@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
encoder := json.NewEncoder(out)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(result); err != nil {
|
if err := encoder.Encode(result); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = vfs.WriteFile(outputPath, png, 0644)
|
err = vfs.WriteFile(outputPath, png, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error {
|
|||||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||||
q, err := qrcode.New(url, qrcode.Medium)
|
q, err := qrcode.New(url, qrcode.Medium)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprint(w, q.ToSmallString(false))
|
fmt.Fprint(w, q.ToSmallString(false))
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -171,29 +170,15 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "missing_url" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "missing_output" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,15 +188,8 @@ func TestRunQRCode_InvalidSize(t *testing.T) {
|
|||||||
Size: 16,
|
Size: 16,
|
||||||
Output: "qr.png",
|
Output: "qr.png",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "invalid_size" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,15 +199,8 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
|||||||
Size: 2048,
|
Size: 2048,
|
||||||
Output: "qr.png",
|
Output: "qr.png",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "invalid_size" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,12 +210,8 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
|||||||
Size: 256,
|
Size: 256,
|
||||||
Output: "/etc/passwd",
|
Output: "/etc/passwd",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,15 +296,8 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error writing to nonexistent directory")
|
t.Fatal("expected error writing to nonexistent directory")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitInternal {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "write_error" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +318,7 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty string")
|
t.Fatal("expected error for empty string")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Type != "encode_error" {
|
|
||||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -18,6 +19,7 @@ type ScopesOptions struct {
|
|||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Format string
|
Format string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||||
@@ -29,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
|||||||
Short: "Query scopes enabled for the app",
|
Short: "Query scopes enabled for the app",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
|
if opts.JSON {
|
||||||
|
opts.Format = "json"
|
||||||
|
}
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
}
|
}
|
||||||
@@ -37,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -50,11 +56,23 @@ func authScopesRun(opts *ScopesOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
||||||
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
|
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
// Discriminate by error type so transport / parse failures are not
|
||||||
fmt.Sprintf("failed to get app scope info: %v", err),
|
// reclassified as PermissionError(MissingScope) — re-auth does not
|
||||||
"ensure the app has enabled the application:application:self_manage scope.")
|
// fix network / 5xx / JSON parse errors and misclassifying them
|
||||||
|
// here would mislead agents into re-auth loops.
|
||||||
|
// - typed errors pass through unchanged
|
||||||
|
// - bare errors become InternalError(SubtypeSDKError) with Cause
|
||||||
|
// preserved so callers (errors.Is) can still see the underlying
|
||||||
|
// transport/parse failure.
|
||||||
|
// Genuine permission failures are surfaced from appInfo *content*,
|
||||||
|
// not from this transport-level error path.
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||||
|
"failed to get app scope info: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if opts.Format == "pretty" {
|
if opts.Format == "pretty" {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||||
|
|||||||
121
cmd/auth/scopes_test.go
Normal file
121
cmd/auth/scopes_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
||||||
|
// observes a fixed error from the dependency. t.Cleanup restores the prior
|
||||||
|
// value so tests cannot leak through the package-level seam.
|
||||||
|
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||||
|
t.Helper()
|
||||||
|
prev := getAppInfoFn
|
||||||
|
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||||
|
return nil, errToReturn
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { getAppInfoFn = prev })
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
||||||
|
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
||||||
|
// and reach the getAppInfoFn call.
|
||||||
|
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||||
|
t.Helper()
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app",
|
||||||
|
AppSecret: "test-secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
return &ScopesOptions{
|
||||||
|
Factory: f,
|
||||||
|
Ctx: context.Background(),
|
||||||
|
Format: "json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
|
||||||
|
// surfaced by the dependency is not re-classified as PermissionError —
|
||||||
|
// re-auth does not fix DNS / transport failures and blanket-wrapping them
|
||||||
|
// would mislead agents into infinite re-auth loops.
|
||||||
|
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
|
||||||
|
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
|
||||||
|
stubGetAppInfoErr(t, netErr)
|
||||||
|
|
||||||
|
err := authScopesRun(scopesTestFactory(t))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var permErr *errs.PermissionError
|
||||||
|
if errors.As(err, &permErr) {
|
||||||
|
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
|
||||||
|
}
|
||||||
|
var gotNet *errs.NetworkError
|
||||||
|
if !errors.As(err, &gotNet) {
|
||||||
|
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if gotNet != netErr {
|
||||||
|
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
|
||||||
|
// failures from the dependency also pass through — IsTyped() must not single
|
||||||
|
// out one category.
|
||||||
|
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
|
||||||
|
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
|
||||||
|
WithMissingScopes("im:message")
|
||||||
|
stubGetAppInfoErr(t, permErr)
|
||||||
|
|
||||||
|
err := authScopesRun(scopesTestFactory(t))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
var got *errs.PermissionError
|
||||||
|
if !errors.As(err, &got) {
|
||||||
|
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if got != permErr {
|
||||||
|
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
|
||||||
|
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
|
||||||
|
// *InternalError{SubtypeSDKError} with the original error preserved on
|
||||||
|
// Cause so errors.Is still walks to it.
|
||||||
|
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
|
||||||
|
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
|
||||||
|
stubGetAppInfoErr(t, bareErr)
|
||||||
|
|
||||||
|
err := authScopesRun(scopesTestFactory(t))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var permErr *errs.PermissionError
|
||||||
|
if errors.As(err, &permErr) {
|
||||||
|
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var intErr *errs.InternalError
|
||||||
|
if !errors.As(err, &intErr) {
|
||||||
|
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if intErr.Subtype != errs.SubtypeSDKError {
|
||||||
|
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, bareErr) {
|
||||||
|
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type StatusOptions struct {
|
type StatusOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Verify bool
|
Verify bool
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthStatus creates the auth status subcommand.
|
// NewCmdAuthStatus creates the auth status subcommand.
|
||||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -61,7 +63,6 @@ func authStatusRun(opts *StatusOptions) error {
|
|||||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||||
result["identities"] = diagnostics
|
result["identities"] = diagnostics
|
||||||
result["identity"] = effectiveIdentity(diagnostics)
|
result["identity"] = effectiveIdentity(diagnostics)
|
||||||
addLegacyUserFields(result, diagnostics.User)
|
|
||||||
addEffectiveVerification(result, diagnostics)
|
addEffectiveVerification(result, diagnostics)
|
||||||
addStatusNote(result, diagnostics)
|
addStatusNote(result, diagnostics)
|
||||||
|
|
||||||
@@ -86,29 +87,6 @@ func effectiveIdentity(d identitydiag.Result) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
|
||||||
if user.OpenID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result["userName"] = user.UserName
|
|
||||||
result["userOpenId"] = user.OpenID
|
|
||||||
if user.TokenStatus != "" {
|
|
||||||
result["tokenStatus"] = user.TokenStatus
|
|
||||||
}
|
|
||||||
if user.Scope != "" {
|
|
||||||
result["scope"] = user.Scope
|
|
||||||
}
|
|
||||||
if user.ExpiresAt != "" {
|
|
||||||
result["expiresAt"] = user.ExpiresAt
|
|
||||||
}
|
|
||||||
if user.RefreshExpiresAt != "" {
|
|
||||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
|
||||||
}
|
|
||||||
if user.GrantedAt != "" {
|
|
||||||
result["grantedAt"] = user.GrantedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||||
switch result["identity"] {
|
switch result["identity"] {
|
||||||
case identityUser:
|
case identityUser:
|
||||||
|
|||||||
84
cmd/build.go
84
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
@@ -16,8 +17,10 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/profile"
|
"github.com/larksuite/cli/cmd/profile"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -31,9 +34,13 @@ import (
|
|||||||
type BuildOption func(*buildConfig)
|
type BuildOption func(*buildConfig)
|
||||||
|
|
||||||
type buildConfig struct {
|
type buildConfig struct {
|
||||||
streams *cmdutil.IOStreams
|
streams *cmdutil.IOStreams
|
||||||
keychain keychain.KeychainAccess
|
keychain keychain.KeychainAccess
|
||||||
globals GlobalOptions
|
globals GlobalOptions
|
||||||
|
skipPlugins bool
|
||||||
|
skipStrictMode bool
|
||||||
|
skipService bool
|
||||||
|
serviceCatalog *apicatalog.Catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||||
@@ -51,6 +58,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||||
|
// at build time. It is registered by the repo-root package main's init via
|
||||||
|
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||||
|
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||||
|
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||||
|
var embeddedSkillContent fs.FS
|
||||||
|
|
||||||
|
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||||
|
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||||
|
// supply its own skill content.
|
||||||
|
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||||
|
|
||||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||||
// When hide is true the flag stays registered (so existing invocations still
|
// When hide is true the flag stays registered (so existing invocations still
|
||||||
// parse) but is omitted from help and shell completion. Typically called as
|
// parse) but is omitted from help and shell completion. Typically called as
|
||||||
@@ -61,6 +80,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
|
// Build constructs the full command tree. It also installs registered
|
||||||
// plugins and emits the Startup lifecycle event during assembly --
|
// plugins and emits the Startup lifecycle event during assembly --
|
||||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||||
@@ -103,6 +157,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
if cfg.keychain != nil {
|
if cfg.keychain != nil {
|
||||||
f.Keychain = cfg.keychain
|
f.Keychain = cfg.keychain
|
||||||
}
|
}
|
||||||
|
f.SkillContent = embeddedSkillContent
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "lark-cli",
|
Use: "lark-cli",
|
||||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||||
@@ -117,6 +172,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
installTipsHelpFunc(rootCmd)
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
|
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||||
|
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||||
|
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||||
|
// inherited by every subcommand, turning unknown-flag errors into a
|
||||||
|
// structured "did you mean" envelope.
|
||||||
|
rootCmd.SilenceUsage = true
|
||||||
|
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||||
|
|
||||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||||
@@ -133,15 +195,27 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||||
|
if !cfg.skipService {
|
||||||
|
if cfg.serviceCatalog != nil {
|
||||||
|
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||||
|
} else {
|
||||||
|
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
installUnknownSubcommandGuard(rootCmd)
|
installUnknownSubcommandGuard(rootCmd)
|
||||||
|
|
||||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.skipPlugins {
|
||||||
|
recordInventory(nil)
|
||||||
|
return f, rootCmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||||
if installErr != nil {
|
if installErr != nil {
|
||||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||||
|
|||||||
46
cmd/build_test.go
Normal file
46
cmd/build_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
|
||||||
|
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||||
|
|
||||||
|
if root == nil {
|
||||||
|
t.Fatal("Build returned nil root")
|
||||||
|
}
|
||||||
|
if findCommand(root, "api") == nil {
|
||||||
|
t.Fatal("builtin api command missing")
|
||||||
|
}
|
||||||
|
if findCommand(root, "docs +fetch") == nil {
|
||||||
|
t.Fatal("builtin docs +fetch shortcut missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCommand(root *cobra.Command, path string) *cobra.Command {
|
||||||
|
parts := strings.Fields(path)
|
||||||
|
cmd := root
|
||||||
|
for _, part := range parts {
|
||||||
|
var next *cobra.Command
|
||||||
|
for _, child := range cmd.Commands() {
|
||||||
|
if child.Name() == part {
|
||||||
|
next = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd = next
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
160
cmd/cmdexample_catalog_test.go
Normal file
160
cmd/cmdexample_catalog_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// universalFlags are accepted by every command (cobra auto-injects help; the
|
||||||
|
// root injects version). They are never reported as unknown.
|
||||||
|
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
||||||
|
|
||||||
|
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
||||||
|
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
||||||
|
// "contact +search-user". The root command is the empty path "".
|
||||||
|
type catalog struct {
|
||||||
|
flagsByPath map[string]map[string]bool
|
||||||
|
group map[string]bool // paths that are parent groups (have subcommands)
|
||||||
|
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCatalog() *catalog {
|
||||||
|
return &catalog{
|
||||||
|
flagsByPath: map[string]map[string]bool{},
|
||||||
|
group: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setGroup records whether path is a parent group (has subcommands). Leftover
|
||||||
|
// words after a group node are unknown subcommands; after a leaf they are
|
||||||
|
// positionals (e.g. "api GET /path").
|
||||||
|
func (c *catalog) setGroup(path string, isGroup bool) {
|
||||||
|
if isGroup {
|
||||||
|
c.group[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
||||||
|
|
||||||
|
// addCommand registers a command path and the flags it accepts. Repeated calls
|
||||||
|
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
||||||
|
func (c *catalog) addCommand(path string, flags []string) {
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
if set == nil {
|
||||||
|
set = map[string]bool{}
|
||||||
|
c.flagsByPath[path] = set
|
||||||
|
}
|
||||||
|
for _, f := range flags {
|
||||||
|
set[f] = true
|
||||||
|
}
|
||||||
|
c.sorted = nil // invalidate cached suggestion list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *catalog) hasCommand(path string) bool {
|
||||||
|
_, ok := c.flagsByPath[path]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFlag reports whether flag is accepted by command path (universal flags
|
||||||
|
// always pass).
|
||||||
|
func (c *catalog) hasFlag(path, flag string) bool {
|
||||||
|
if universalFlags[flag] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
return set[flag]
|
||||||
|
}
|
||||||
|
|
||||||
|
// longestPrefix returns the longest known command path that is a prefix of
|
||||||
|
// words, plus how many words it consumed. This separates real subcommands from
|
||||||
|
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
||||||
|
// empty it falls back to the root command. ok=false means not even the first
|
||||||
|
// word names a command.
|
||||||
|
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
||||||
|
if len(words) == 0 {
|
||||||
|
if c.hasCommand("") {
|
||||||
|
return "", 0, true
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
for i := len(words); i >= 1; i-- {
|
||||||
|
cand := strings.Join(words[:i], " ")
|
||||||
|
if c.hasCommand(cand) {
|
||||||
|
return cand, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// paths returns all known command paths, sorted.
|
||||||
|
func (c *catalog) paths() []string {
|
||||||
|
out := make([]string, 0, len(c.flagsByPath))
|
||||||
|
for p := range c.flagsByPath {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestCommand returns the known command path closest to want (small edit
|
||||||
|
// distance), for error hints. Returns "" when nothing is reasonably close.
|
||||||
|
func (c *catalog) suggestCommand(want string) string {
|
||||||
|
if c.sorted == nil {
|
||||||
|
c.sorted = c.paths() // built once after the catalog is fully populated
|
||||||
|
}
|
||||||
|
return closest(want, c.sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestFlag returns the flag of path closest to flag, for error hints.
|
||||||
|
func (c *catalog) suggestFlag(path, flag string) string {
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
cands := make([]string, 0, len(set))
|
||||||
|
for f := range set {
|
||||||
|
cands = append(cands, f)
|
||||||
|
}
|
||||||
|
sort.Strings(cands)
|
||||||
|
return closest(flag, cands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// closest returns the candidate with the smallest Levenshtein distance to want,
|
||||||
|
// but only if that distance is within a tolerance scaled to want's length
|
||||||
|
// (avoids absurd suggestions).
|
||||||
|
func closest(want string, cands []string) string {
|
||||||
|
best := ""
|
||||||
|
bestD := 1 << 30
|
||||||
|
for _, cand := range cands {
|
||||||
|
d := levenshtein(want, cand)
|
||||||
|
if d < bestD {
|
||||||
|
bestD, best = d, cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tol := len(want)/2 + 1
|
||||||
|
if bestD > tol {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshtein(a, b string) int {
|
||||||
|
ra, rb := []rune(a), []rune(b)
|
||||||
|
prev := make([]int, len(rb)+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(ra); i++ {
|
||||||
|
cur := make([]int, len(rb)+1)
|
||||||
|
cur[0] = i
|
||||||
|
for j := 1; j <= len(rb); j++ {
|
||||||
|
cost := 1
|
||||||
|
if ra[i-1] == rb[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
return prev[len(rb)]
|
||||||
|
}
|
||||||
60
cmd/cmdexample_check_test.go
Normal file
60
cmd/cmdexample_check_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Finding kinds.
|
||||||
|
const (
|
||||||
|
unknownCommand = "unknown_command"
|
||||||
|
unknownFlag = "unknown_flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// finding is a single mismatch between an example command reference and the
|
||||||
|
// catalog.
|
||||||
|
type finding struct {
|
||||||
|
line int
|
||||||
|
raw string
|
||||||
|
kind string // unknownCommand | unknownFlag
|
||||||
|
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
|
||||||
|
flag string // offending flag (unknownFlag only)
|
||||||
|
suggest string // nearest known command/flag, "" if none close
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefs validates refs against cat and returns all mismatches in order.
|
||||||
|
func checkRefs(cat *catalog, refs []ref) []finding {
|
||||||
|
var out []finding
|
||||||
|
for _, r := range refs {
|
||||||
|
path, n, ok := cat.longestPrefix(r.words)
|
||||||
|
if !ok {
|
||||||
|
attempted := strings.Join(r.words, " ")
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||||
|
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftover words after a group node are an unknown subcommand (e.g. a
|
||||||
|
// mistyped method like "batch_modify_message"). After a leaf they are
|
||||||
|
// positionals (e.g. "api GET /path"), so only groups trigger this.
|
||||||
|
if n < len(r.words) && cat.isGroup(path) {
|
||||||
|
attempted := strings.Join(r.words, " ")
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||||
|
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range r.flags {
|
||||||
|
if cat.hasFlag(path, f) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownFlag,
|
||||||
|
path: path, flag: f, suggest: cat.suggestFlag(path, f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
222
cmd/cmdexample_parse_test.go
Normal file
222
cmd/cmdexample_parse_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ref is one lark-cli command reference extracted from a shortcut example.
|
||||||
|
type ref struct {
|
||||||
|
line int // 1-based line number (the line where the command starts)
|
||||||
|
raw string // reconstructed command text, for error display
|
||||||
|
words []string // command words before the first flag (subcommand candidates)
|
||||||
|
flags []string // flag tokens used, e.g. "--query", "-q"
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliToken = "lark-cli"
|
||||||
|
|
||||||
|
// subcommandStart guards against false positives from prose: a real command's
|
||||||
|
// first word is ASCII (a service name or a +shortcut). A token starting with
|
||||||
|
// CJK / punctuation is treated as narration, not a command.
|
||||||
|
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
|
||||||
|
|
||||||
|
// shellStops are standalone tokens that terminate a command (pipes, redirects,
|
||||||
|
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
|
||||||
|
var shellStops = map[string]bool{
|
||||||
|
"|": true, "||": true, "&&": true, "&": true, ";": true,
|
||||||
|
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
|
||||||
|
// in prose ("auth login." / "auth login,"); stripped so the word still resolves
|
||||||
|
// instead of being dropped as an unknown command or non-ASCII narration.
|
||||||
|
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
|
||||||
|
|
||||||
|
// parseRefs extracts every lark-cli command reference from text (a shortcut's
|
||||||
|
// Tips line, which may embed an "Example: lark-cli ..." command). It is
|
||||||
|
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
|
||||||
|
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
|
||||||
|
// line-continuations are joined first so a multi-line invocation is parsed as
|
||||||
|
// one command; inline-code backticks and trailing # comments terminate it.
|
||||||
|
func parseRefs(content string) []ref {
|
||||||
|
var refs []ref
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
lineNo := i + 1
|
||||||
|
logical := lines[i]
|
||||||
|
// Shell line continuation: a trailing backslash joins the next physical
|
||||||
|
// line. Without this, flags on the continuation lines of a multi-line
|
||||||
|
// `lark-cli ... \` example are never seen by the checker.
|
||||||
|
for endsWithBackslash(logical) && i+1 < len(lines) {
|
||||||
|
logical = strings.TrimRight(logical, " \t")
|
||||||
|
logical = logical[:len(logical)-1] // drop the trailing backslash
|
||||||
|
i++
|
||||||
|
logical += " " + lines[i]
|
||||||
|
}
|
||||||
|
refs = append(refs, parseLine(logical, lineNo)...)
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func endsWithBackslash(s string) bool {
|
||||||
|
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLine(line string, lineNo int) []ref {
|
||||||
|
var refs []ref
|
||||||
|
rest := line
|
||||||
|
for {
|
||||||
|
idx := strings.Index(rest, cliToken)
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
after := rest[idx+len(cliToken):]
|
||||||
|
beforeOK := idx == 0 || isBoundary(rest[idx-1])
|
||||||
|
afterOK := after == "" || isBoundary(after[0])
|
||||||
|
if beforeOK && afterOK {
|
||||||
|
if words, flags, raw, ok := parseCmd(after); ok {
|
||||||
|
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = after
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmd tokenizes the text following "lark-cli" into leading command words
|
||||||
|
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
|
||||||
|
// shell separator (standalone or glued), an inline-code backtick, a comment, or
|
||||||
|
// a placeholder/prose word. ok=false filters out non-commands.
|
||||||
|
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
|
||||||
|
// An inline code span ends at the next backtick; a command never spans one.
|
||||||
|
if i := strings.IndexByte(after, '`'); i >= 0 {
|
||||||
|
after = after[:i]
|
||||||
|
}
|
||||||
|
// Drop $(...) command substitutions so flags belonging to the inner command
|
||||||
|
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
|
||||||
|
after = stripCmdSubst(after)
|
||||||
|
|
||||||
|
var kept []string
|
||||||
|
inFlags := false
|
||||||
|
for _, orig := range strings.Fields(after) {
|
||||||
|
tok := orig
|
||||||
|
if shellStops[tok] || strings.HasPrefix(tok, "#") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// A shell separator glued to a token ends the command mid-token
|
||||||
|
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
|
||||||
|
stop := false
|
||||||
|
if i := strings.IndexAny(tok, ";|"); i >= 0 {
|
||||||
|
tok, stop = tok[:i], true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case tok == "" || tok == "-":
|
||||||
|
// empty (after a glued separator) or a bare stdin marker — skip
|
||||||
|
case strings.HasPrefix(tok, "-"):
|
||||||
|
if f := normalizeFlag(tok); f != "" {
|
||||||
|
inFlags = true
|
||||||
|
flags = append(flags, f)
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
case inFlags:
|
||||||
|
// positional / flag value after the first flag — not a command word
|
||||||
|
kept = append(kept, tok)
|
||||||
|
default:
|
||||||
|
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
|
||||||
|
// +<verb>, ...) end the command — checked on the RAW token so the
|
||||||
|
// trailing-punct stripping below cannot erase a "..." ellipsis
|
||||||
|
// ("base +..." must stay a placeholder, not become "+").
|
||||||
|
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Strip trailing sentence/CJK punctuation so "login." / "login,"
|
||||||
|
// resolve to "login"; non-ASCII narration ends the command.
|
||||||
|
w := strings.TrimRight(tok, wordTrailPunct)
|
||||||
|
if w == "" || hasNonASCII(w) {
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
words = append(words, w)
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
if stop {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
raw = " " + strings.Join(kept, " ")
|
||||||
|
}
|
||||||
|
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
|
||||||
|
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
|
||||||
|
if len(words) == 0 {
|
||||||
|
return words, flags, raw, len(flags) > 0
|
||||||
|
}
|
||||||
|
if !subcommandStart.MatchString(words[0]) {
|
||||||
|
return nil, nil, "", false
|
||||||
|
}
|
||||||
|
return words, flags, raw, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCmdSubst removes $(...) command substitutions (including nested ones)
|
||||||
|
// from s, leaving the surrounding text intact. Backtick substitutions are
|
||||||
|
// already handled upstream (a command never spans a backtick).
|
||||||
|
func stripCmdSubst(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||||
|
depth = 1
|
||||||
|
i++ // skip '('
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
switch s[i] {
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholderOrProse reports whether a command word is a doc placeholder
|
||||||
|
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
|
||||||
|
// non-ASCII), rather than a literal command token.
|
||||||
|
func isPlaceholderOrProse(w string) bool {
|
||||||
|
if hasNonASCII(w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNonASCII(s string) bool {
|
||||||
|
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagShape matches the leading flag token, stripping any trailing junk such as
|
||||||
|
// a "=value" suffix or punctuation that bled in from the surrounding markdown
|
||||||
|
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
|
||||||
|
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
|
||||||
|
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
|
||||||
|
|
||||||
|
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
|
||||||
|
// a real flag (e.g. a shell-string fragment like "-草稿'").
|
||||||
|
func normalizeFlag(tok string) string {
|
||||||
|
return flagShape.FindString(tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBoundary(b byte) bool {
|
||||||
|
switch b {
|
||||||
|
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
113
cmd/cmdexample_test.go
Normal file
113
cmd/cmdexample_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// This file and its cmdexample_*_test.go siblings implement a test-only check:
|
||||||
|
// the example commands embedded in shortcut definitions (the "Example: lark-cli
|
||||||
|
// ..." lines in each shortcut's Tips, shown in --help) must match the real
|
||||||
|
// command tree. It lives entirely in _test.go files (package cmd_test) so it
|
||||||
|
// ships in no binary and is not importable by product code; the truth source is
|
||||||
|
// cmd.Build, the same tree the binary uses, so the check cannot drift.
|
||||||
|
//
|
||||||
|
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
|
||||||
|
// example using a renamed command or an unaccepted flag — fails that job.
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/cmd"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/shortcuts"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShortcutExampleCommands checks the example commands embedded in every
|
||||||
|
// shortcut's Tips against the live command tree. A shortcut that defines no
|
||||||
|
// example is simply skipped.
|
||||||
|
//
|
||||||
|
// Because the examples and the command definitions live in the same Go code,
|
||||||
|
// this is a self-consistency check: any mismatch (an example using a renamed
|
||||||
|
// command or a flag the command doesn't accept) is a bug to fix at the source.
|
||||||
|
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
|
||||||
|
// always a defect, never acceptable "pre-existing drift".
|
||||||
|
func TestShortcutExampleCommands(t *testing.T) {
|
||||||
|
// Reproducibility: use the embedded API metadata (not a developer's stale
|
||||||
|
// ~/.lark-cli remote cache, which can miss commands) and an empty config
|
||||||
|
// dir so local strict mode / plugins / policy cannot reshape the tree.
|
||||||
|
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
cat := buildCmdExampleCatalog()
|
||||||
|
|
||||||
|
type located struct {
|
||||||
|
shortcut string
|
||||||
|
f finding
|
||||||
|
}
|
||||||
|
var findings []located
|
||||||
|
for _, sc := range shortcuts.AllShortcuts() {
|
||||||
|
var refs []ref
|
||||||
|
for _, tip := range sc.Tips {
|
||||||
|
refs = append(refs, parseRefs(tip)...)
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(sc.Service + " " + sc.Command)
|
||||||
|
for _, f := range checkRefs(cat, refs) {
|
||||||
|
findings = append(findings, located{shortcut: label, f: f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
|
||||||
|
for _, lf := range findings {
|
||||||
|
hint := ""
|
||||||
|
if lf.f.suggest != "" {
|
||||||
|
hint = " (did you mean " + lf.f.suggest + "?)"
|
||||||
|
}
|
||||||
|
if lf.f.kind == unknownFlag {
|
||||||
|
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
|
||||||
|
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||||
|
} else {
|
||||||
|
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
|
||||||
|
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
|
||||||
|
"fix the Example in the shortcut definition.", len(findings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCmdExampleCatalog walks the live cobra command tree and records every
|
||||||
|
// command path (minus the "lark-cli" root prefix) with its accepted flags and
|
||||||
|
// whether it is a parent group. This is the same Build() the binary uses, so
|
||||||
|
// the catalog can never drift from the real commands.
|
||||||
|
func buildCmdExampleCatalog() *catalog {
|
||||||
|
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||||
|
cat := newCatalog()
|
||||||
|
var walk func(c *cobra.Command)
|
||||||
|
walk = func(c *cobra.Command) {
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||||
|
var flags []string
|
||||||
|
add := func(fl *pflag.Flag) {
|
||||||
|
flags = append(flags, "--"+fl.Name)
|
||||||
|
if fl.Shorthand != "" {
|
||||||
|
flags = append(flags, "-"+fl.Shorthand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Flags().VisitAll(add)
|
||||||
|
c.InheritedFlags().VisitAll(add)
|
||||||
|
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
|
||||||
|
cat.addCommand(path, flags)
|
||||||
|
cat.setGroup(path, c.HasSubCommands())
|
||||||
|
for _, sub := range c.Commands() {
|
||||||
|
walk(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return cat
|
||||||
|
}
|
||||||
233
cmd/cmdexample_units_test.go
Normal file
233
cmd/cmdexample_units_test.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCatalog() *catalog {
|
||||||
|
c := newCatalog()
|
||||||
|
c.addCommand("", []string{"--profile"}) // root
|
||||||
|
c.setGroup("", true)
|
||||||
|
c.addCommand("contact", []string{"--profile"})
|
||||||
|
c.setGroup("contact", true)
|
||||||
|
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
|
||||||
|
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
|
||||||
|
c.addCommand("mail", nil)
|
||||||
|
c.setGroup("mail", true)
|
||||||
|
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
|
||||||
|
c.setGroup("mail user_mailbox.messages", true)
|
||||||
|
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
if !c.hasCommand("contact +search-user") {
|
||||||
|
t.Fatal("expected contact +search-user to exist")
|
||||||
|
}
|
||||||
|
if c.hasCommand("contact +nope") {
|
||||||
|
t.Fatal("did not expect contact +nope")
|
||||||
|
}
|
||||||
|
if !c.hasFlag("contact +search-user", "--query") {
|
||||||
|
t.Fatal("--query should be valid")
|
||||||
|
}
|
||||||
|
if c.hasFlag("contact +search-user", "--nope") {
|
||||||
|
t.Fatal("--nope should be invalid")
|
||||||
|
}
|
||||||
|
// universal flags pass on any command
|
||||||
|
for _, f := range []string{"--help", "-h", "--version"} {
|
||||||
|
if !c.hasFlag("contact +search-user", f) {
|
||||||
|
t.Fatalf("universal flag %s should pass", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleLongestPrefix(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
tests := []struct {
|
||||||
|
words []string
|
||||||
|
want string
|
||||||
|
wantN int
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
|
||||||
|
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
|
||||||
|
{[]string{"nope"}, "", 0, false},
|
||||||
|
{nil, "", 0, true}, // empty -> root
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, n, ok := c.longestPrefix(tt.words)
|
||||||
|
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
|
||||||
|
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
|
||||||
|
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refWordsOf(refs []ref) [][]string {
|
||||||
|
var out [][]string
|
||||||
|
for _, r := range refs {
|
||||||
|
out = append(out, r.words)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
|
||||||
|
"```bash",
|
||||||
|
"lark-cli api GET /open-apis/x --params '{}'", // bash block
|
||||||
|
"```",
|
||||||
|
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
|
||||||
|
"npx foo | lark-cli api GET /y", // after a pipe
|
||||||
|
}, "\n")
|
||||||
|
refs := parseRefs(content)
|
||||||
|
if len(refs) != 4 {
|
||||||
|
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
|
||||||
|
}
|
||||||
|
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
|
||||||
|
len(got.flags) != 1 || got.flags[0] != "--query" {
|
||||||
|
t.Errorf("ref0 = %+v", got)
|
||||||
|
}
|
||||||
|
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
|
||||||
|
t.Errorf("ref1 words = %v", got.words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
|
||||||
|
// A line whose first word is prose yields no command at all.
|
||||||
|
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
|
||||||
|
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
|
||||||
|
}
|
||||||
|
// Syntax templates / trailing prose may leave a real leading word ("mail"),
|
||||||
|
// but no placeholder or CJK token may leak into the command words — that is
|
||||||
|
// what prevents false positives like an "<resource>" unknown-command report.
|
||||||
|
for _, line := range []string{
|
||||||
|
"lark-cli mail <resource> <method> [flags]",
|
||||||
|
"lark-cli apps +<verb> [flags]",
|
||||||
|
"lark-cli base +...",
|
||||||
|
"lark-cli mail 写信场景下的格式说明",
|
||||||
|
} {
|
||||||
|
for _, r := range parseRefs(line) {
|
||||||
|
for _, w := range r.words {
|
||||||
|
if isPlaceholderOrProse(w) {
|
||||||
|
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
|
||||||
|
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
|
||||||
|
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||||
|
}
|
||||||
|
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
|
||||||
|
t.Errorf("expected flag --help, got %v", refs[0].flags)
|
||||||
|
}
|
||||||
|
// bare "-" (stdin marker) and "=value" suffix
|
||||||
|
refs = parseRefs("lark-cli api GET /x --params={} --data -")
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||||
|
}
|
||||||
|
flags := strings.Join(refs[0].flags, " ")
|
||||||
|
if flags != "--params --data" {
|
||||||
|
t.Errorf("expected '--params --data', got %q", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCheck(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
r ref
|
||||||
|
wantKind string // "" = no finding
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
|
||||||
|
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
|
||||||
|
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
|
||||||
|
{"group leftover = unknown subcommand",
|
||||||
|
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
|
||||||
|
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
|
||||||
|
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
|
||||||
|
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := checkRefs(c, []ref{tt.r})
|
||||||
|
if tt.wantKind == "" {
|
||||||
|
if len(fs) != 0 {
|
||||||
|
t.Fatalf("expected no finding, got %+v", fs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(fs) != 1 {
|
||||||
|
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
|
||||||
|
}
|
||||||
|
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
|
||||||
|
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
|
||||||
|
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
|
||||||
|
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
|
||||||
|
// review: backslash continuation, underscore flags, $(...) substitution, glued
|
||||||
|
// separators, trailing punctuation, and the "..." placeholder.
|
||||||
|
func TestCmdExampleParseRefsRobustness(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, content, wantWords, wantFlags string
|
||||||
|
wantRefs int
|
||||||
|
}{
|
||||||
|
{"backslash continuation joins flags",
|
||||||
|
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
|
||||||
|
"contact +search-user", "--query --as", 1},
|
||||||
|
{"underscore flag not truncated",
|
||||||
|
"lark-cli whiteboard +update --input_format mermaid",
|
||||||
|
"whiteboard +update", "--input_format", 1},
|
||||||
|
{"command-substitution flags ignored",
|
||||||
|
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
|
||||||
|
"slides x create", "--data --as", 1},
|
||||||
|
{"glued separator truncates",
|
||||||
|
"lark-cli auth login; echo done",
|
||||||
|
"auth login", "", 1},
|
||||||
|
{"trailing CJK punctuation stripped",
|
||||||
|
"用 lark-cli auth login。",
|
||||||
|
"auth login", "", 1},
|
||||||
|
{"ellipsis placeholder stays placeholder",
|
||||||
|
"lark-cli base +...",
|
||||||
|
"base", "", 1},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
refs := parseRefs(tt.content)
|
||||||
|
if len(refs) != tt.wantRefs {
|
||||||
|
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
|
||||||
|
}
|
||||||
|
if tt.wantRefs == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
|
||||||
|
t.Errorf("words=%q want %q", got, tt.wantWords)
|
||||||
|
}
|
||||||
|
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
|
||||||
|
t.Errorf("flags=%q want %q", got, tt.wantFlags)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
52
cmd/command_catalog_path_test.go
Normal file
52
cmd/command_catalog_path_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||||
|
// service command tree for any depth — flat dotted resources AND genuinely
|
||||||
|
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||||
|
// assuming a fixed root->service->resource->method shape.
|
||||||
|
func TestCommandCatalogPath(t *testing.T) {
|
||||||
|
chain := func(names ...string) *cobra.Command {
|
||||||
|
var parent, leaf *cobra.Command
|
||||||
|
for _, n := range names {
|
||||||
|
c := &cobra.Command{Use: n}
|
||||||
|
if parent != nil {
|
||||||
|
parent.AddCommand(c)
|
||||||
|
}
|
||||||
|
parent = c
|
||||||
|
leaf = c
|
||||||
|
}
|
||||||
|
return leaf
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
leaf *cobra.Command
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||||
|
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||||
|
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root command (no parent) has no catalog path.
|
||||||
|
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||||
|
t.Errorf("root path = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
package completion
|
package completion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
|||||||
case "powershell":
|
case "powershell":
|
||||||
return root.GenPowerShellCompletionWithDesc(out)
|
return root.GenPowerShellCompletionWithDesc(out)
|
||||||
default:
|
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")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -37,8 +39,10 @@ type BindOptions struct {
|
|||||||
// this flag because its own prompts already require human confirmation.
|
// this flag because its own prompts already require human confirmation.
|
||||||
Force bool
|
Force bool
|
||||||
|
|
||||||
Lang string
|
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
|
||||||
langExplicit bool // true when --lang was explicitly passed
|
langExplicit bool // true when --lang was explicitly passed
|
||||||
|
|
||||||
|
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
||||||
|
|
||||||
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
||||||
// the account being bound. Populated after resolveAccount; TUI stages
|
// the account being bound. Populated after resolveAccount; TUI stages
|
||||||
@@ -55,7 +59,7 @@ type BindOptions struct {
|
|||||||
|
|
||||||
// NewCmdConfigBind creates the config bind subcommand.
|
// NewCmdConfigBind creates the config bind subcommand.
|
||||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||||
opts := &BindOptions{Factory: f}
|
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "bind",
|
Use: "bind",
|
||||||
@@ -102,7 +106,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
|||||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
||||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -147,7 +151,7 @@ func configBindRun(opts *BindOptions) error {
|
|||||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyPreferences(appConfig, opts)
|
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||||
noticeUserDefaultRisk(opts)
|
noticeUserDefaultRisk(opts)
|
||||||
|
|
||||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||||
@@ -178,7 +182,7 @@ type existingBinding struct {
|
|||||||
func finalizeSource(opts *BindOptions) (string, error) {
|
func finalizeSource(opts *BindOptions) (string, error) {
|
||||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
|
||||||
}
|
}
|
||||||
|
|
||||||
var detected string
|
var detected string
|
||||||
@@ -195,23 +199,23 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
// before any interactive prompts — running inside Hermes with
|
// before any interactive prompts — running inside Hermes with
|
||||||
// --source openclaw (or vice versa) is almost always a mistake.
|
// --source openclaw (or vice versa) is almost always a mistake.
|
||||||
if explicit != "" && detected != "" && explicit != detected {
|
if explicit != "" && detected != "" && explicit != detected {
|
||||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
"--source %q does not match detected Agent environment (%s)", explicit, detected).
|
||||||
"remove --source to auto-detect, or run this command in the correct Agent context")
|
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
|
||||||
|
WithParam("--source")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TUI: prompt for language before any downstream prompts. The source
|
// TUI: prompt for language before any downstream prompts. The source
|
||||||
// selection itself may still be skipped entirely if --source or the
|
// selection itself may still be skipped entirely if --source or the
|
||||||
// env already pinned it.
|
// env already pinned it. Picker offers 2 options (中文 / English) and
|
||||||
|
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
|
||||||
if opts.IsTUI && !opts.langExplicit {
|
if opts.IsTUI && !opts.langExplicit {
|
||||||
lang, err := promptLangSelection("")
|
lang, err := promptLangSelection()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == huh.ErrUserAborted {
|
return "", langSelectionError(err)
|
||||||
return "", output.ErrBare(1)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
opts.Lang = lang
|
opts.Lang = string(lang)
|
||||||
|
opts.UILang = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
if explicit != "" {
|
if explicit != "" {
|
||||||
@@ -223,9 +227,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
if opts.IsTUI {
|
if opts.IsTUI {
|
||||||
return tuiSelectSource(opts)
|
return tuiSelectSource(opts)
|
||||||
}
|
}
|
||||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
"cannot determine Agent source: no --source flag and no Agent environment detected").
|
||||||
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
|
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
|
||||||
|
WithParam("--source")
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||||
@@ -245,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
|||||||
return existingBinding{}, err
|
return existingBinding{}, err
|
||||||
}
|
}
|
||||||
if action == "cancel" {
|
if action == "cancel" {
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||||
return existingBinding{Cancelled: true}, nil
|
return existingBinding{Cancelled: true}, nil
|
||||||
}
|
}
|
||||||
@@ -329,9 +334,10 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
|||||||
if !hasStrictBotLock(previousConfigBytes) {
|
if !hasStrictBotLock(previousConfigBytes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
||||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
||||||
|
WithHint("%s", msg.IdentityEscalationHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||||
@@ -347,14 +353,23 @@ func noticeUserDefaultRisk(opts *BindOptions) {
|
|||||||
if opts.IsTUI || opts.Identity != "user-default" {
|
if opts.IsTUI || opts.Identity != "user-default" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPreferences expands the chosen identity preset into the underlying
|
// applyPreferences expands the chosen identity preset into the underlying
|
||||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||||
// profile's intent survives later changes to global strict-mode settings.
|
// profile's intent survives later changes to global strict-mode settings.
|
||||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
// preferredLang resolves the language to persist: the requested value when set,
|
||||||
|
// otherwise the prior one — so an unset --lang never clears a stored preference.
|
||||||
|
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
||||||
|
if requested != "" {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
return prior
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only":
|
case "bot-only":
|
||||||
sm := core.StrictModeBot
|
sm := core.StrictModeBot
|
||||||
@@ -365,9 +380,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
|||||||
appConfig.StrictMode = &sm
|
appConfig.StrictMode = &sm
|
||||||
appConfig.DefaultAs = core.AsUser
|
appConfig.DefaultAs = core.AsUser
|
||||||
}
|
}
|
||||||
if opts.Lang != "" {
|
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
||||||
appConfig.Lang = opts.Lang
|
}
|
||||||
|
|
||||||
|
// priorLang returns the language preference recorded in a previous config, or
|
||||||
|
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
|
||||||
|
// fallback) — scanning all apps for the first non-empty Lang would leak the
|
||||||
|
// 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
|
||||||
|
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
if app := multi.CurrentAppConfig(""); app != nil {
|
||||||
|
return app.Lang
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
||||||
@@ -379,21 +408,21 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||||
|
|
||||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||||
"failed to create workspace directory: %v", err)
|
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(multi, "", " ")
|
data, err := json.MarshalIndent(multi, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
||||||
"failed to marshal config: %v", err)
|
|
||||||
}
|
}
|
||||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
||||||
"failed to write config %s: %v", configPath, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced := previousConfigBytes != nil
|
replaced := previousConfigBytes != nil
|
||||||
msg := getBindMsg(opts.Lang)
|
// uiMsg renders human-facing TUI text (stderr success banner). Follows
|
||||||
|
// opts.UILang — zh by default; picker can flip it to en. --lang does
|
||||||
|
// not influence the TUI language.
|
||||||
|
uiMsg := getBindMsg(opts.UILang)
|
||||||
display := sourceDisplayName(source)
|
display := sourceDisplayName(source)
|
||||||
|
|
||||||
if replaced {
|
if replaced {
|
||||||
@@ -401,7 +430,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||||
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
|
||||||
|
|
||||||
|
if opts.langExplicit && opts.Lang != "" {
|
||||||
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
|
||||||
|
}
|
||||||
|
|
||||||
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
||||||
// stderr is enough and a machine-readable JSON dump on stdout is just
|
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||||
@@ -419,12 +452,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
"replaced": replaced,
|
"replaced": replaced,
|
||||||
"identity": opts.Identity,
|
"identity": opts.Identity,
|
||||||
}
|
}
|
||||||
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
// JSON "message" follows the effective preference on disk (appConfig.Lang),
|
||||||
|
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
|
||||||
|
// has already inherited the prior preference into appConfig.Lang, and the
|
||||||
|
// message should respect that inherited choice. stderr above follows UILang.
|
||||||
|
prefMsg := getBindMsg(appConfig.Lang)
|
||||||
|
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
|
||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only":
|
case "bot-only":
|
||||||
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||||
case "user-default":
|
case "user-default":
|
||||||
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(envelope)
|
resultJSON, _ := json.Marshal(envelope)
|
||||||
@@ -461,7 +499,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
|
|||||||
|
|
||||||
// tuiSelectSource prompts user to choose bind source.
|
// tuiSelectSource prompts user to choose bind source.
|
||||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
var source string
|
var source string
|
||||||
|
|
||||||
// Pre-select based on detected env signals
|
// Pre-select based on detected env signals
|
||||||
@@ -486,7 +524,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
|||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[string]().
|
huh.NewSelect[string]().
|
||||||
Title(msg.SelectSource).
|
Title(msg.SelectSource).
|
||||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||||
@@ -508,7 +546,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
|||||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
options := make([]huh.Option[int], 0, len(candidates))
|
options := make([]huh.Option[int], 0, len(candidates))
|
||||||
for i, c := range candidates {
|
for i, c := range candidates {
|
||||||
label := c.AppID
|
label := c.AppID
|
||||||
@@ -522,7 +560,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
|||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[int]().
|
huh.NewSelect[int]().
|
||||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
|
||||||
Options(options...).
|
Options(options...).
|
||||||
Value(&selected),
|
Value(&selected),
|
||||||
),
|
),
|
||||||
@@ -539,7 +577,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
|||||||
|
|
||||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
|
|
||||||
// Build existing binding summary
|
// Build existing binding summary
|
||||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||||
@@ -588,9 +626,14 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only", "user-default":
|
case "bot-only", "user-default":
|
||||||
default:
|
default:
|
||||||
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.Lang = string(lang)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,8 +649,8 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
// DescriptionFunc approach breaks here because a longer description on
|
// DescriptionFunc approach breaks here because a longer description on
|
||||||
// hover pushes options out of the field's initial viewport.
|
// hover pushes options out of the field's initial viewport.
|
||||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||||
msg := getBindMsg(opts.Lang)
|
msg := getBindMsg(opts.UILang)
|
||||||
brand := brandDisplay(opts.Brand, opts.Lang)
|
brand := brandDisplay(opts.Brand, opts.UILang)
|
||||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||||
var value string
|
var value string
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/internal/i18n"
|
||||||
|
|
||||||
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
||||||
//
|
//
|
||||||
// Brand-aware strings use a %s slot where the UI-friendly product name
|
// Brand-aware strings use a %s slot where the UI-friendly product name
|
||||||
@@ -84,6 +86,11 @@ type bindMsg struct {
|
|||||||
// require in-flow human confirmation.
|
// require in-flow human confirmation.
|
||||||
IdentityEscalationMessage string
|
IdentityEscalationMessage string
|
||||||
IdentityEscalationHint string
|
IdentityEscalationHint string
|
||||||
|
|
||||||
|
// LangPreferenceSet is printed to stderr after a successful bind when the
|
||||||
|
// user explicitly passed --lang. Format: language code. Not printed when
|
||||||
|
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
|
||||||
|
LangPreferenceSet string
|
||||||
}
|
}
|
||||||
|
|
||||||
var bindMsgZh = &bindMsg{
|
var bindMsgZh = &bindMsg{
|
||||||
@@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{
|
|||||||
|
|
||||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||||
|
|
||||||
|
LangPreferenceSet: "语言偏好已设置:%s",
|
||||||
}
|
}
|
||||||
|
|
||||||
var bindMsgEn = &bindMsg{
|
var bindMsgEn = &bindMsg{
|
||||||
@@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{
|
|||||||
|
|
||||||
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
|
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
|
||||||
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
||||||
|
|
||||||
|
LangPreferenceSet: "Language preference set to: %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBindMsg(lang string) *bindMsg {
|
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||||
if lang == "en" {
|
func getBindMsg(lang i18n.Lang) *bindMsg {
|
||||||
|
if lang.IsEnglish() {
|
||||||
return bindMsgEn
|
return bindMsgEn
|
||||||
}
|
}
|
||||||
return bindMsgZh
|
return bindMsgZh
|
||||||
@@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg {
|
|||||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
||||||
// this is the safe default when the brand hasn't been resolved yet (for
|
// this is the safe default when the brand hasn't been resolved yet (for
|
||||||
// example, on the pre-binding source-selection screen).
|
// example, on the pre-binding source-selection screen).
|
||||||
func brandDisplay(brand, lang string) string {
|
func brandDisplay(brand string, lang i18n.Lang) string {
|
||||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||||
return "Lark"
|
return "Lark"
|
||||||
}
|
}
|
||||||
if lang == "en" {
|
if lang.IsEnglish() {
|
||||||
return "Feishu"
|
return "Feishu"
|
||||||
}
|
}
|
||||||
return "飞书"
|
return "飞书"
|
||||||
|
|||||||
@@ -16,42 +16,50 @@ import (
|
|||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertExitError checks the full structured error in one assertion. It
|
// wantErrDetail is the normalized comparison shape for a typed error's wire
|
||||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
// fields: Type is the error's Category string ("validation", "config", ...),
|
||||||
// typed validation error — they normalize to the same wantDetail fields.
|
// alongside Message and Hint.
|
||||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
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()
|
t.Helper()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got 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
|
var ve *errs.ValidationError
|
||||||
if errors.As(err, &ve) {
|
if errors.As(err, &ve) {
|
||||||
if got := output.ExitCodeOf(err); got != wantCode {
|
if got := output.ExitCodeOf(err); got != wantCode {
|
||||||
t.Errorf("exit code = %d, want %d", 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) {
|
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
|
var ce *errs.ConfigError
|
||||||
|
if errors.As(err, &ce) {
|
||||||
|
if got := output.ExitCodeOf(err); got != wantCode {
|
||||||
|
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||||
|
}
|
||||||
|
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||||
|
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||||
|
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||||
@@ -120,14 +128,235 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if gotOpts.Lang != "zh" {
|
if gotOpts.Lang != "" {
|
||||||
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
|
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
|
||||||
}
|
}
|
||||||
if gotOpts.langExplicit {
|
if gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=false when --lang not passed")
|
t.Error("expected langExplicit=false when --lang not passed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
|
||||||
|
// validated: wrong case, typos, and removed codes all exit with
|
||||||
|
// ExitValidation (code 2) and a message identifying the offending value.
|
||||||
|
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
|
||||||
|
func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||||
|
hermesHome := t.TempDir()
|
||||||
|
t.Setenv("HERMES_HOME", hermesHome)
|
||||||
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write .env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lang string
|
||||||
|
}{
|
||||||
|
{"wrong case ZH", "ZH"},
|
||||||
|
{"typo frr", "frr"},
|
||||||
|
{"removed code ar", "ar"},
|
||||||
|
{"unknown xx", "xx"},
|
||||||
|
{"hyphen form zh-CN", "zh-CN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{
|
||||||
|
Factory: f,
|
||||||
|
Source: "hermes",
|
||||||
|
Lang: tc.lang,
|
||||||
|
langExplicit: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||||
|
}
|
||||||
|
var valErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &valErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
|
||||||
|
// explicit "") is unset: it neither errors nor persists a language, while a
|
||||||
|
// non-empty short code or Feishu locale both canonicalize to the same locale.
|
||||||
|
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lang string
|
||||||
|
explicit bool
|
||||||
|
wantLang i18n.Lang
|
||||||
|
}{
|
||||||
|
{"omitted", "", false, ""},
|
||||||
|
{"explicit empty", "", true, ""},
|
||||||
|
{"short code", "ja", true, i18n.LangJaJP},
|
||||||
|
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
hermesHome := t.TempDir()
|
||||||
|
t.Setenv("HERMES_HOME", hermesHome)
|
||||||
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write .env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{
|
||||||
|
Factory: f,
|
||||||
|
Source: "hermes",
|
||||||
|
Lang: tc.lang,
|
||||||
|
langExplicit: tc.explicit,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
multi, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
app := multi.CurrentAppConfig("")
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("no app persisted")
|
||||||
|
}
|
||||||
|
if app.Lang != tc.wantLang {
|
||||||
|
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
|
||||||
|
// --lang silently dropping a previously stored preference (appConfig is rebuilt
|
||||||
|
// fresh, so commitBinding must inherit the prior Lang).
|
||||||
|
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
hermesHome := t.TempDir()
|
||||||
|
t.Setenv("HERMES_HOME", hermesHome)
|
||||||
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write .env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
|
||||||
|
t.Fatalf("first bind (--lang ja): %v", err)
|
||||||
|
}
|
||||||
|
f2, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
||||||
|
t.Fatalf("re-bind (no --lang): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
multi, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
||||||
|
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
|
||||||
|
// and silently returning a non-current profile's Lang. In a multi-profile
|
||||||
|
// 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{
|
||||||
|
CurrentApp: "active",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
|
||||||
|
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bytes, err := json.Marshal(multi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if got := priorLang(bytes); got != i18n.LangEnUS {
|
||||||
|
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
|
||||||
|
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
|
||||||
|
// 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{
|
||||||
|
{AppId: "cli_only", Lang: i18n.LangJaJP},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bytes, err := json.Marshal(multi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if got := priorLang(bytes); got != i18n.LangJaJP {
|
||||||
|
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
|
||||||
|
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
|
||||||
|
if got := priorLang([]byte("not json")); got != "" {
|
||||||
|
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
|
||||||
|
// "message" field against regressing to opts.Lang: when --lang is omitted on
|
||||||
|
// re-bind, the inherited preference (appConfig.Lang) must drive the message
|
||||||
|
// language and the embedded brand display — otherwise an AI agent that set
|
||||||
|
// English on first bind sees Chinese in every subsequent re-bind envelope.
|
||||||
|
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
hermesHome := t.TempDir()
|
||||||
|
t.Setenv("HERMES_HOME", hermesHome)
|
||||||
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write .env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
|
||||||
|
t.Fatalf("first bind (--lang en): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
||||||
|
t.Fatalf("re-bind (no --lang): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope := map[string]any{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v", err)
|
||||||
|
}
|
||||||
|
msg, _ := envelope["message"].(string)
|
||||||
|
enMsg := getBindMsg(i18n.LangEnUS)
|
||||||
|
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
|
||||||
|
if msg != wantMsg {
|
||||||
|
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
||||||
|
|
||||||
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||||
@@ -136,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "validation",
|
Type: "validation",
|
||||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
||||||
})
|
})
|
||||||
@@ -153,8 +382,8 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
// TestFactory has IsTerminal=false by default
|
// TestFactory has IsTerminal=false by default
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
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",
|
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||||
})
|
})
|
||||||
@@ -192,8 +421,8 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
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",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -208,8 +437,8 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
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",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -337,8 +566,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||||
})
|
})
|
||||||
@@ -355,8 +584,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify OpenClaw is installed and configured",
|
Hint: "verify OpenClaw is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -502,8 +731,8 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
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",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -521,8 +750,8 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify lark-channel-bridge is installed and configured",
|
Hint: "verify lark-channel-bridge is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -541,8 +770,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "accounts.app.id missing in " + configPath,
|
Message: "accounts.app.id missing in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||||
})
|
})
|
||||||
@@ -560,8 +789,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "accounts.app.secret is empty in " + configPath,
|
Message: "accounts.app.secret is empty in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||||
})
|
})
|
||||||
@@ -606,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
|||||||
t.Fatal("expected error for unbound workspace")
|
t.Fatal("expected error for unbound workspace")
|
||||||
}
|
}
|
||||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
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
|
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||||
// binding exists yet, which is a config error.
|
// binding exists yet, which is a config error.
|
||||||
if cfgErr.Code != output.ExitAuth {
|
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||||
}
|
}
|
||||||
if cfgErr.Type != "openclaw" {
|
// The workspace name stays out of the wire subtype; it only appears in
|
||||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
// the message.
|
||||||
|
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||||
|
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||||
@@ -912,12 +1143,8 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for multi-account without --app-id, got nil")
|
t.Fatal("expected error for multi-account without --app-id, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,8 +1189,8 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
||||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||||
base := output.ErrDetail{
|
base := wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
}
|
}
|
||||||
wantWorkFirst := base
|
wantWorkFirst := base
|
||||||
@@ -971,20 +1198,17 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
wantPersonalFirst := base
|
wantPersonalFirst := base
|
||||||
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
var ve *errs.ValidationError
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil {
|
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||||
t.Fatal("expected non-nil error detail")
|
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
|
||||||
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
|
||||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||||
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
|
got, wantWorkFirst, wantPersonalFirst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,8 +1232,8 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only_one",
|
Hint: "available app IDs:\n cli_only_one",
|
||||||
})
|
})
|
||||||
@@ -1028,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "validation",
|
Type: "validation",
|
||||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||||
})
|
})
|
||||||
@@ -1141,11 +1365,19 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
|||||||
Identity: "user-default",
|
Identity: "user-default",
|
||||||
})
|
})
|
||||||
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
var ce *errs.ConfirmationRequiredError
|
||||||
Type: "bind",
|
if !errors.As(err, &ce) {
|
||||||
Message: msg.IdentityEscalationMessage,
|
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err)
|
||||||
Hint: msg.IdentityEscalationHint,
|
}
|
||||||
})
|
if ce.Risk != errs.RiskHighRiskWrite {
|
||||||
|
t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite)
|
||||||
|
}
|
||||||
|
if ce.Message != msg.IdentityEscalationMessage {
|
||||||
|
t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage)
|
||||||
|
}
|
||||||
|
if ce.Hint != msg.IdentityEscalationHint {
|
||||||
|
t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Config on disk must remain untouched — the gate runs before
|
// Config on disk must remain untouched — the gate runs before
|
||||||
// commitBinding writes anything.
|
// commitBinding writes anything.
|
||||||
@@ -1306,8 +1538,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1326,8 +1558,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1352,8 +1584,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "openclaw.json missing channels.feishu section",
|
Message: "openclaw.json missing channels.feishu section",
|
||||||
Hint: "configure Feishu in OpenClaw first",
|
Hint: "configure Feishu in OpenClaw first",
|
||||||
})
|
})
|
||||||
@@ -1380,8 +1612,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
|||||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -1442,8 +1674,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -1474,10 +1706,14 @@ func TestGetBindMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
|
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
|
||||||
msg := getBindMsg("fr")
|
// Only zh and en TUI bundles exist; any non-English language (canonical
|
||||||
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
// locale, short code, or unrecognized value) falls back to zh.
|
||||||
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
|
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
|
||||||
|
msg := getBindMsg(lang)
|
||||||
|
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
||||||
|
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1640,3 +1876,36 @@ func TestHasStrictBotLock(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
|
||||||
|
// confirmation line: when --lang is explicit, bind prints "language preference
|
||||||
|
// set" to stderr (rendered in the TUI language, embedding the preference value).
|
||||||
|
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||||
|
|
||||||
|
hermesHome := t.TempDir()
|
||||||
|
t.Setenv("HERMES_HOME", hermesHome)
|
||||||
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write .env: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{
|
||||||
|
Factory: f,
|
||||||
|
Source: "hermes",
|
||||||
|
Identity: "bot-only",
|
||||||
|
Lang: "en",
|
||||||
|
langExplicit: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got error: %v", err)
|
||||||
|
}
|
||||||
|
// The short --lang en is canonicalized to en_us before the confirmation
|
||||||
|
// echoes it back; the TUI language stays zh (flag mode, no picker).
|
||||||
|
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
|
||||||
|
if got := stderr.String(); !strings.Contains(got, want) {
|
||||||
|
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/binding"
|
"github.com/larksuite/cli/internal/binding"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
|||||||
case "lark-channel":
|
case "lark-channel":
|
||||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||||
default:
|
default:
|
||||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +85,10 @@ func selectCandidate(
|
|||||||
// from ListCandidates itself and never reach here.
|
// from ListCandidates itself and never reach here.
|
||||||
switch src {
|
switch src {
|
||||||
case "openclaw":
|
case "openclaw":
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
|
||||||
"no Feishu app configured in openclaw.json",
|
WithHint("configure channels.feishu.appId in openclaw.json")
|
||||||
"configure channels.feishu.appId in openclaw.json")
|
|
||||||
default:
|
default:
|
||||||
return nil, output.ErrValidation("%s: no app configured", src)
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +98,9 @@ func selectCandidate(
|
|||||||
return &candidates[i], nil
|
return &candidates[i], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
|
||||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(candidates) == 1 {
|
if len(candidates) == 1 {
|
||||||
@@ -112,9 +111,9 @@ func selectCandidate(
|
|||||||
return tuiPrompt(candidates)
|
return tuiPrompt(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
|
||||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||||
@@ -149,14 +148,13 @@ func (b *openclawBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
WithHint("verify OpenClaw is installed and configured").
|
||||||
"verify OpenClaw is installed and configured")
|
WithCause(err)
|
||||||
}
|
}
|
||||||
if cfg.Channels.Feishu == nil {
|
if cfg.Channels.Feishu == nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
|
||||||
"openclaw.json missing channels.feishu section",
|
WithHint("configure Feishu in OpenClaw first")
|
||||||
"configure Feishu in OpenClaw first")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||||
@@ -172,8 +170,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected *binding.CandidateApp
|
var selected *binding.CandidateApp
|
||||||
@@ -184,26 +181,25 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
||||||
"internal: appID %q not in candidates", appID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected.AppSecret.IsZero() {
|
if selected.AppSecret.IsZero() {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||||
"configure channels.feishu.appSecret in openclaw.json")
|
|
||||||
}
|
}
|
||||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
WithHint("check appSecret configuration in %s", b.path).
|
||||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -229,15 +225,14 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||||
envMap, err := readDotenv(b.path)
|
envMap, err := readDotenv(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
|
||||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
WithHint("verify Hermes is installed and configured at %s", b.path).
|
||||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
appID := envMap["FEISHU_APP_ID"]
|
appID := envMap["FEISHU_APP_ID"]
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
|
||||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||||
"run 'hermes setup' to configure Feishu credentials")
|
|
||||||
}
|
}
|
||||||
b.envMap = envMap
|
b.envMap = envMap
|
||||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||||
@@ -245,24 +240,22 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.envMap == nil {
|
if b.envMap == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
|
||||||
"internal: appID %q does not match env", appID)
|
|
||||||
}
|
}
|
||||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||||
if appSecret == "" {
|
if appSecret == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
|
||||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||||
"run 'hermes setup' to configure Feishu credentials")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -290,14 +283,13 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
WithHint("verify lark-channel-bridge is installed and configured").
|
||||||
"verify lark-channel-bridge is installed and configured")
|
WithCause(err)
|
||||||
}
|
}
|
||||||
if cfg.Accounts.App.ID == "" {
|
if cfg.Accounts.App.ID == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
|
||||||
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||||
"run lark-channel-bridge's setup to populate the app credential")
|
|
||||||
}
|
}
|
||||||
b.cfg = cfg
|
b.cfg = cfg
|
||||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||||
@@ -305,32 +297,30 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.ID != appID {
|
if b.cfg.Accounts.App.ID != appID {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
|
||||||
"internal: appID %q does not match config", appID)
|
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
|
||||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||||
"run lark-channel-bridge's setup to populate the app credential")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
// 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 := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
WithHint("check appSecret configuration in %s", b.path).
|
||||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -389,10 +379,12 @@ func resolveHermesEnvPath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
||||||
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
|
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
|
||||||
// ~/.lark-channel/config.json with no env override — multi-instance is not
|
// single-account config without changing lark-cli's target config directory.
|
||||||
// a supported scenario today.
|
|
||||||
func resolveLarkChannelConfigPath() string {
|
func resolveLarkChannelConfigPath() string {
|
||||||
|
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
|
||||||
|
return expandHome(p)
|
||||||
|
}
|
||||||
home, err := vfs.UserHomeDir()
|
home, err := vfs.UserHomeDir()
|
||||||
if err != nil || home == "" {
|
if err != nil || home == "" {
|
||||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -50,8 +51,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
|||||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -63,8 +64,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
|||||||
// even before it has a bespoke error message.
|
// even before it has a bespoke error message.
|
||||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||||
Type: "validation",
|
Type: "config",
|
||||||
Message: "hermes: no app configured",
|
Message: "hermes: no app configured",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,8 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
|||||||
{AppID: "cli_home", Label: "home"},
|
{AppID: "cli_home", Label: "home"},
|
||||||
}
|
}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||||
})
|
})
|
||||||
@@ -116,8 +117,8 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
|||||||
{AppID: "cli_home", Label: "home"},
|
{AppID: "cli_home", Label: "home"},
|
||||||
}
|
}
|
||||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||||
})
|
})
|
||||||
@@ -151,8 +152,8 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
|||||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||||
candidates := []Candidate{{AppID: "cli_only"}}
|
candidates := []Candidate{{AppID: "cli_only"}}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only",
|
Hint: "available app IDs:\n cli_only",
|
||||||
})
|
})
|
||||||
@@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("LARK_CHANNEL_CONFIG", "")
|
||||||
|
|
||||||
|
got := resolveLarkChannelConfigPath()
|
||||||
|
want := filepath.Join(home, ".lark-channel", "config.json")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
|
||||||
|
|
||||||
|
got := resolveLarkChannelConfigPath()
|
||||||
|
want := filepath.Join(home, "bridge", "projection.json")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||||
|
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -91,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
|||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
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.
|
// Config errors share ExitAuth (3), not ExitValidation.
|
||||||
if cfgErr.Code != output.ExitAuth {
|
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||||
}
|
}
|
||||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +127,11 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
|||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
if !strings.Contains(err.Error(), "no active profile") {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
||||||
}
|
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
|
||||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +149,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if gotOpts.Lang != "en" {
|
// --lang en is canonicalized to en_us in RunE before runF captures opts.
|
||||||
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
|
if gotOpts.Lang != string(i18n.LangEnUS) {
|
||||||
|
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
|
||||||
}
|
}
|
||||||
if !gotOpts.langExplicit {
|
if !gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=true when --lang is passed")
|
t.Error("expected langExplicit=true when --lang is passed")
|
||||||
@@ -173,14 +172,88 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if gotOpts.Lang != "zh" {
|
if gotOpts.Lang != "" {
|
||||||
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
|
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
|
||||||
}
|
}
|
||||||
if gotOpts.langExplicit {
|
if gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=false when --lang is not passed")
|
t.Error("expected langExplicit=false when --lang is not passed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
|
||||||
|
// re-running init without --lang must inherit the prior preference, not clear it.
|
||||||
|
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},
|
||||||
|
}}
|
||||||
|
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||||
|
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
||||||
|
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
||||||
|
// strictly validated the same way bind validates: wrong-case / typo / removed
|
||||||
|
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
||||||
|
func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||||
|
clearAgentEnv(t)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lang string
|
||||||
|
}{
|
||||||
|
{"wrong case ZH", "ZH"},
|
||||||
|
{"typo frr", "frr"},
|
||||||
|
{"removed code ar", "ar"},
|
||||||
|
{"unknown xx", "xx"},
|
||||||
|
{"hyphen form zh-CN", "zh-CN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
cmd := NewCmdConfigInit(f, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("sec\n")
|
||||||
|
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||||
|
}
|
||||||
|
var valErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &valErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -319,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected conflict error")
|
t.Fatal("expected conflict error")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
// A name/appId conflict is user input — a typed validation error naming the
|
||||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
// 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,16 +502,65 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
|
||||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
||||||
|
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
||||||
|
// the same locale; an unrecognized value errors.
|
||||||
|
func TestValidateInitLang(t *testing.T) {
|
||||||
|
t.Run("empty is a no-op", func(t *testing.T) {
|
||||||
|
for _, explicit := range []bool{false, true} {
|
||||||
|
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
|
||||||
|
if err := validateInitLang(opts); err != nil {
|
||||||
|
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
|
||||||
|
}
|
||||||
|
if opts.Lang != "" {
|
||||||
|
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("short and locale canonicalize alike", func(t *testing.T) {
|
||||||
|
for _, in := range []string{"ja", "ja_jp"} {
|
||||||
|
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
|
||||||
|
if err := validateInitLang(opts); err != nil {
|
||||||
|
t.Fatalf("--lang %q: unexpected error %v", in, err)
|
||||||
|
}
|
||||||
|
if opts.Lang != string(i18n.LangJaJP) {
|
||||||
|
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
|
||||||
|
// to stderr only when --lang explicitly set a non-empty preference.
|
||||||
|
func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
|
||||||
|
got := stderr.String()
|
||||||
|
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
|
||||||
|
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("implicit prints nothing", func(t *testing.T) {
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("explicit empty prints nothing", func(t *testing.T) {
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Errorf("stderr = %q, want empty when --lang is empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
value := args[0]
|
value := args[0]
|
||||||
if value != "user" && value != "bot" && value != "auto" {
|
if value != "user" && value != "bot" && value != "auto" {
|
||||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.DefaultAs = core.Identity(value)
|
app.DefaultAs = core.Identity(value)
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -31,9 +31,13 @@ type ConfigInitOptions struct {
|
|||||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||||
Brand string
|
Brand string
|
||||||
New bool
|
New bool
|
||||||
Lang string
|
|
||||||
langExplicit bool // true when --lang was explicitly passed
|
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
langExplicit bool // true when --lang was explicitly passed
|
||||||
|
|
||||||
|
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
||||||
|
|
||||||
|
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||||
|
|
||||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||||
@@ -45,7 +49,7 @@ type ConfigInitOptions struct {
|
|||||||
|
|
||||||
// NewCmdConfigInit creates the config init subcommand.
|
// NewCmdConfigInit creates the config init subcommand.
|
||||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||||
opts := &ConfigInitOptions{Factory: f}
|
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
@@ -63,6 +67,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||||
|
if err := validateInitLang(opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := guardAgentWorkspace(opts); err != nil {
|
if err := guardAgentWorkspace(opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -77,7 +84,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
@@ -85,6 +92,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printLangPreferenceConfirmation echoes the set preference to stderr, only
|
||||||
|
// when --lang explicitly set a non-empty value.
|
||||||
|
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
|
||||||
|
if !opts.langExplicit || opts.Lang == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := getInitMsg(opts.UILang)
|
||||||
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInitLang(opts *ConfigInitOptions) error {
|
||||||
|
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.Lang = string(lang)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||||
// Hermes Agent context, because the Agent has already provisioned an app
|
// Hermes Agent context, because the Agent has already provisioned an app
|
||||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||||
@@ -99,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
|||||||
if ws.IsLocal() {
|
if ws.IsLocal() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &core.ConfigError{
|
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||||
Code: 2,
|
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
|
||||||
Type: 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.")
|
||||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
|
||||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||||
@@ -132,7 +155,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
|||||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||||
config := &core.MultiAppConfig{
|
config := &core.MultiAppConfig{
|
||||||
Apps: []core.AppConfig{{
|
Apps: []core.AppConfig{{
|
||||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
|
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
return core.SaveMultiAppConfig(config)
|
return core.SaveMultiAppConfig(config)
|
||||||
@@ -146,7 +169,27 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
|||||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||||
}
|
}
|
||||||
cleanupOldConfig(existing, f, appId)
|
cleanupOldConfig(existing, f, appId)
|
||||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
var prior i18n.Lang
|
||||||
|
if existing != nil {
|
||||||
|
if app := existing.CurrentAppConfig(""); app != nil {
|
||||||
|
prior = app.Lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
// saveAsProfile appends or updates a named profile in the config.
|
||||||
@@ -167,14 +210,15 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
|||||||
}
|
}
|
||||||
multi.Apps[idx].Users = []core.AppUser{}
|
multi.Apps[idx].Users = []core.AppUser{}
|
||||||
}
|
}
|
||||||
// Update existing profile
|
|
||||||
multi.Apps[idx].AppId = appId
|
multi.Apps[idx].AppId = appId
|
||||||
multi.Apps[idx].AppSecret = secret
|
multi.Apps[idx].AppSecret = secret
|
||||||
multi.Apps[idx].Brand = brand
|
multi.Apps[idx].Brand = brand
|
||||||
multi.Apps[idx].Lang = lang
|
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||||
} else {
|
} else {
|
||||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
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
|
// Append new profile
|
||||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||||
@@ -182,7 +226,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
|||||||
AppId: appId,
|
AppId: appId,
|
||||||
AppSecret: secret,
|
AppSecret: secret,
|
||||||
Brand: brand,
|
Brand: brand,
|
||||||
Lang: lang,
|
Lang: i18n.Lang(lang),
|
||||||
Users: []core.AppUser{},
|
Users: []core.AppUser{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -213,9 +257,25 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrapUpdateExistingProfileErr classifies the error returned by
|
||||||
|
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
|
||||||
|
// for blank-input) pass through unchanged so their exit code semantics
|
||||||
|
// survive; everything else (filesystem, keychain, etc.) is wrapped as
|
||||||
|
// InternalError.
|
||||||
|
func wrapUpdateExistingProfileErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
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 *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||||
|
WithParam("--app-secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
var app *core.AppConfig
|
var app *core.AppConfig
|
||||||
@@ -223,22 +283,25 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
|||||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||||
app = &existing.Apps[idx]
|
app = &existing.Apps[idx]
|
||||||
} else {
|
} else {
|
||||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||||
|
WithParam("--app-secret")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app = existing.CurrentAppConfig("")
|
app = existing.CurrentAppConfig("")
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||||
|
WithParam("--app-secret")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.AppId != appID {
|
if app.AppId != appID {
|
||||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
|
||||||
|
WithParam("--app-secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
app.AppId = appID
|
app.AppId = appID
|
||||||
app.Brand = brand
|
app.Brand = brand
|
||||||
app.Lang = lang
|
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
||||||
return core.SaveMultiAppConfig(existing)
|
return core.SaveMultiAppConfig(existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,13 +313,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
return output.ErrValidation("stdin is empty, expected app secret")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
|
||||||
}
|
}
|
||||||
opts.appSecret = strings.TrimSpace(scanner.Text())
|
opts.appSecret = strings.TrimSpace(scanner.Text())
|
||||||
if opts.appSecret == "" {
|
if opts.appSecret == "" {
|
||||||
return output.ErrValidation("app secret read from stdin is empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +331,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
// Validate --profile name if set
|
// Validate --profile name if set
|
||||||
if opts.ProfileName != "" {
|
if opts.ProfileName != "" {
|
||||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||||
return output.ErrValidation("%v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,35 +340,33 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
brand := parseBrand(opts.Brand)
|
brand := parseBrand(opts.Brand)
|
||||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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", core.GetConfigPath()))
|
||||||
|
printLangPreferenceConfirmation(opts)
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||||
|
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
// For interactive modes, prompt language selection if --lang was not explicitly set.
|
||||||
|
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
|
||||||
|
// (preference) and opts.UILang (TUI rendering).
|
||||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||||
savedLang := ""
|
lang, err := promptLangSelection()
|
||||||
if existing != nil {
|
|
||||||
if app := existing.CurrentAppConfig(""); app != nil {
|
|
||||||
savedLang = app.Lang
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lang, err := promptLangSelection(savedLang)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == huh.ErrUserAborted {
|
return langSelectionError(err)
|
||||||
return output.ErrBare(1)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
opts.Lang = lang
|
opts.Lang = string(lang)
|
||||||
|
opts.UILang = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := getInitMsg(opts.Lang)
|
msg := getInitMsg(opts.UILang)
|
||||||
|
|
||||||
// Mode 3: Create new app directly (--new)
|
// Mode 3: Create new app directly (--new)
|
||||||
if opts.New {
|
if opts.New {
|
||||||
@@ -314,17 +375,21 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return output.ErrValidation("app creation returned no result")
|
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||||
}
|
}
|
||||||
existing, _ := core.LoadMultiAppConfig()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
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 {
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return wrapSaveConfigError(err)
|
||||||
}
|
}
|
||||||
|
printLangPreferenceConfirmation(opts)
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||||
|
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +400,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||||
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := core.LoadMultiAppConfig()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
@@ -344,34 +410,36 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
// New secret provided (either from "create" or "existing" with input)
|
// New secret provided (either from "create" or "existing" with input)
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
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 {
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return wrapSaveConfigError(err)
|
||||||
}
|
}
|
||||||
} else if result.Mode == "existing" && result.AppID != "" {
|
} else if result.Mode == "existing" && result.AppID != "" {
|
||||||
// Existing app with unchanged secret — update app ID and brand only
|
// Existing app with unchanged secret — update app ID and brand only
|
||||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
|
||||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
return err
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||||
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Mode == "existing" {
|
if result.Mode == "existing" {
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||||
}
|
}
|
||||||
|
printLangPreferenceConfirmation(opts)
|
||||||
|
if result.AppSecret != "" {
|
||||||
|
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||||
if !f.IOStreams.IsTerminal {
|
if !f.IOStreams.IsTerminal {
|
||||||
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 5: Legacy interactive (readline fallback)
|
// Mode 5: Legacy interactive (readline fallback)
|
||||||
@@ -399,7 +467,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appIdInput, err := readLine(prompt)
|
appIdInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "App Secret"
|
prompt = "App Secret"
|
||||||
@@ -408,7 +476,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appSecretInput, err := readLine(prompt)
|
appSecretInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "Brand (lark/feishu)"
|
prompt = "Brand (lark/feishu)"
|
||||||
@@ -419,7 +487,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
brandInput, err := readLine(prompt)
|
brandInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedAppId := appIdInput
|
resolvedAppId := appIdInput
|
||||||
@@ -441,16 +509,23 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
||||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||||
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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", core.GetConfigPath()))
|
||||||
|
printLangPreferenceConfirmation(opts)
|
||||||
|
if appSecretInput != "" {
|
||||||
|
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||||
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||||
}
|
}
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
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" {
|
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
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") {
|
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected refusal in Hermes context, got nil")
|
t.Fatal("expected refusal in Hermes context, got nil")
|
||||||
}
|
}
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
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" {
|
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ package config
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configInitResult holds the result of the interactive config init flow.
|
// configInitResult holds the result of the interactive config init flow.
|
||||||
@@ -125,8 +126,16 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if appID == "" || appSecret == "" {
|
switch {
|
||||||
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
|
case appID == "" && appSecret == "":
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||||
|
WithParam("--app-id")
|
||||||
|
case appID == "":
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
|
||||||
|
WithParam("--app-id")
|
||||||
|
case appSecret == "":
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
|
||||||
|
WithParam("--app-secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &configInitResult{
|
return &configInitResult{
|
||||||
@@ -168,10 +177,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Request app registration (begin)
|
// Step 1: Request app registration (begin)
|
||||||
httpClient := &http.Client{}
|
// 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(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrAuth("app registration failed: %v", err)
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Build and display verification URL + QR code
|
// Step 2: Build and display verification URL + QR code
|
||||||
@@ -199,7 +210,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
}
|
}
|
||||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrAuth("%v", err)
|
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Handle Lark brand special case
|
// Step 4: Handle Lark brand special case
|
||||||
@@ -208,12 +219,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
// 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)
|
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.ClientID == "" || result.ClientSecret == "" {
|
if result.ClientID == "" || result.ClientSecret == "" {
|
||||||
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine final brand from response
|
// Determine final brand from response
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
type initMsg struct {
|
type initMsg struct {
|
||||||
@@ -26,6 +31,10 @@ type initMsg struct {
|
|||||||
DetectedLarkTenant string
|
DetectedLarkTenant string
|
||||||
AppCreated string
|
AppCreated string
|
||||||
ConfigSaved string
|
ConfigSaved string
|
||||||
|
|
||||||
|
// LangPreferenceSet is printed to stderr after a successful init when the
|
||||||
|
// user explicitly passed --lang. Format: language code.
|
||||||
|
LangPreferenceSet string
|
||||||
}
|
}
|
||||||
|
|
||||||
var initMsgZh = &initMsg{
|
var initMsgZh = &initMsg{
|
||||||
@@ -43,6 +52,7 @@ var initMsgZh = &initMsg{
|
|||||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||||
AppCreated: "应用配置成功! App ID: %s",
|
AppCreated: "应用配置成功! App ID: %s",
|
||||||
ConfigSaved: "应用配置成功! App ID: %s",
|
ConfigSaved: "应用配置成功! App ID: %s",
|
||||||
|
LangPreferenceSet: "语言偏好已设置:%s",
|
||||||
}
|
}
|
||||||
|
|
||||||
var initMsgEn = &initMsg{
|
var initMsgEn = &initMsg{
|
||||||
@@ -60,29 +70,27 @@ var initMsgEn = &initMsg{
|
|||||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||||
AppCreated: "App configured! App ID: %s",
|
AppCreated: "App configured! App ID: %s",
|
||||||
ConfigSaved: "App configured! App ID: %s",
|
ConfigSaved: "App configured! App ID: %s",
|
||||||
|
LangPreferenceSet: "Language preference set to: %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInitMsg(lang string) *initMsg {
|
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||||
if lang == "en" {
|
func getInitMsg(lang i18n.Lang) *initMsg {
|
||||||
|
if lang.IsEnglish() {
|
||||||
return initMsgEn
|
return initMsgEn
|
||||||
}
|
}
|
||||||
return initMsgZh
|
return initMsgZh
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
|
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
|
||||||
// savedLang is used as the pre-selected default (from existing config).
|
func promptLangSelection() (i18n.Lang, error) {
|
||||||
func promptLangSelection(savedLang string) (string, error) {
|
lang := i18n.LangZhCN
|
||||||
lang := savedLang
|
|
||||||
if lang != "en" {
|
|
||||||
lang = "zh"
|
|
||||||
}
|
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[string]().
|
huh.NewSelect[i18n.Lang]().
|
||||||
Title("Language / 语言").
|
Title("Language / 语言").
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption("中文", "zh"),
|
huh.NewOption("中文", i18n.LangZhCN),
|
||||||
huh.NewOption("English", "en"),
|
huh.NewOption("English", i18n.LangEnUS),
|
||||||
).
|
).
|
||||||
Value(&lang),
|
Value(&lang),
|
||||||
),
|
),
|
||||||
@@ -93,3 +101,12 @@ func promptLangSelection(savedLang string) (string, error) {
|
|||||||
}
|
}
|
||||||
return lang, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetInitMsg_Zh(t *testing.T) {
|
func TestGetInitMsg_Zh(t *testing.T) {
|
||||||
@@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
||||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
|
||||||
msg := getInitMsg(lang)
|
msg := getInitMsg(lang)
|
||||||
if msg != initMsgZh {
|
if msg != initMsgZh {
|
||||||
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
||||||
@@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
|||||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||||
"AppCreated": msg.AppCreated,
|
"AppCreated": msg.AppCreated,
|
||||||
"ConfigSaved": msg.ConfigSaved,
|
"ConfigSaved": msg.ConfigSaved,
|
||||||
|
"LangPreferenceSet": msg.LangPreferenceSet,
|
||||||
}
|
}
|
||||||
for name, val := range fields {
|
for name, val := range fields {
|
||||||
if val == "" {
|
if val == "" {
|
||||||
@@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInitMsg_FormatStrings(t *testing.T) {
|
func TestInitMsg_FormatStrings(t *testing.T) {
|
||||||
for _, lang := range []string{"zh", "en"} {
|
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||||
msg := getInitMsg(lang)
|
msg := getInitMsg(lang)
|
||||||
// AppCreated and ConfigSaved should contain %s for App ID
|
// AppCreated and ConfigSaved should contain %s for App ID
|
||||||
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
||||||
@@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
|
||||||
|
// The TUI is bilingual (zh + en). Only English-bucket languages return the
|
||||||
|
// English struct — by canonical locale ("en_us") or legacy short ("en").
|
||||||
|
// Everything else (zh, the other codes, invalid, "") returns Chinese.
|
||||||
|
tests := []struct {
|
||||||
|
lang i18n.Lang
|
||||||
|
shouldBeEn bool
|
||||||
|
}{
|
||||||
|
{i18n.LangZhCN, false},
|
||||||
|
{i18n.LangEnUS, true},
|
||||||
|
{"en", true}, // legacy short value
|
||||||
|
{i18n.LangJaJP, false},
|
||||||
|
{"fr_fr", false},
|
||||||
|
{"invalid", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.lang), func(t *testing.T) {
|
||||||
|
msg := getInitMsg(tt.lang)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatal("getInitMsg returned nil")
|
||||||
|
}
|
||||||
|
want := initMsgZh
|
||||||
|
if tt.shouldBeEn {
|
||||||
|
want = initMsgEn
|
||||||
|
}
|
||||||
|
if msg != want {
|
||||||
|
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
92
cmd/config/init_probe.go
Normal file
92
cmd/config/init_probe.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||||
|
// (covering both TAT acquisition and the subsequent probe request).
|
||||||
|
const probeTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// runProbe runs a best-effort credential validation after config init has
|
||||||
|
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||||
|
// deterministic credential-rejection signal; every other outcome returns nil
|
||||||
|
// so that valid configurations and transient/upstream noise never block the
|
||||||
|
// command.
|
||||||
|
//
|
||||||
|
// The function performs up to two HTTP calls in series, bounded by
|
||||||
|
// probeTimeout:
|
||||||
|
//
|
||||||
|
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||||
|
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||||
|
// only when the unified Token Endpoint deterministically rejected the
|
||||||
|
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||||
|
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||||
|
// typed error is propagated so the root dispatcher renders the canonical
|
||||||
|
// envelope and `config init` exits non-zero — identical to how every other
|
||||||
|
// token-resolving command reports the same bad credentials. Ambiguous
|
||||||
|
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||||
|
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||||
|
// so valid configurations are never disturbed by upstream noise.
|
||||||
|
// errs.IsTyped is the discriminator.
|
||||||
|
//
|
||||||
|
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||||
|
// that call (success, server error, timeout, parse failure) is always
|
||||||
|
// ignored — return nil regardless.
|
||||||
|
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||||
|
if factory == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
httpClient, err := factory.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||||
|
if err != nil {
|
||||||
|
// A typed error from FetchTAT is a deterministic credential rejection
|
||||||
|
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||||
|
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||||
|
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||||
|
// silent and let the command succeed.
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||||
|
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||||
|
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
287
cmd/config/init_probe_test.go
Normal file
287
cmd/config/init_probe_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||||
|
type fakeRT struct {
|
||||||
|
tatHandler func(req *http.Request) (*http.Response, error)
|
||||||
|
probeHandler func(req *http.Request) (*http.Response, error)
|
||||||
|
tatCalls int
|
||||||
|
probeCalls int
|
||||||
|
probeReq *http.Request
|
||||||
|
probeBody string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||||
|
f.tatCalls++
|
||||||
|
if f.tatHandler == nil {
|
||||||
|
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||||
|
}
|
||||||
|
return f.tatHandler(req)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||||
|
f.probeCalls++
|
||||||
|
f.probeReq = req
|
||||||
|
if req.Body != nil {
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
f.probeBody = string(b)
|
||||||
|
}
|
||||||
|
if f.probeHandler == nil {
|
||||||
|
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||||
|
}
|
||||||
|
return f.probeHandler(req)
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResp(code int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: code,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||||
|
// the caller-supplied RoundTripper.
|
||||||
|
//
|
||||||
|
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||||
|
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||||
|
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||||
|
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||||
|
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||||
|
// touch lands in a temp dir rather than the developer's real config.
|
||||||
|
//
|
||||||
|
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||||
|
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||||
|
// this buffer stays empty as an invariant.
|
||||||
|
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.HttpClient = func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
}
|
||||||
|
return f, errBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||||
|
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||||
|
// is the same typed error every other token-resolving command returns for the
|
||||||
|
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||||
|
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||||
|
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||||
|
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected *errs.ConfigError, got nil")
|
||||||
|
}
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if !errors.As(err, &cfgErr) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if cfgErr.Category != errs.CategoryConfig {
|
||||||
|
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||||
|
}
|
||||||
|
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||||
|
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||||
|
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||||
|
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil (silent), got error: %v", err)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||||
|
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||||
|
// propagated. The probe endpoint must not be called when TAT fails.
|
||||||
|
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
|
||||||
|
if rt.probeCalls != 0 {
|
||||||
|
t.Error("probe endpoint must not be called when TAT fails")
|
||||||
|
}
|
||||||
|
assertConfigRejection(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||||
|
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||||
|
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||||
|
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||||
|
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if err == nil || !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||||
|
// rejection) → silent, exit 0.
|
||||||
|
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||||
|
for _, code := range []int{401, 403, 500} {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(code, `nope`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("network down")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(500, `server error`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if rt.probeCalls != 1 {
|
||||||
|
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||||
|
}
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||||
|
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||||
|
}
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, _ := fakeFactory(t, rt)
|
||||||
|
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rt.probeReq == nil {
|
||||||
|
t.Fatal("probe request not captured")
|
||||||
|
}
|
||||||
|
if rt.probeReq.Method != http.MethodPost {
|
||||||
|
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||||
|
}
|
||||||
|
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||||
|
t.Errorf("probe URL = %s", got)
|
||||||
|
}
|
||||||
|
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||||
|
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, _ := fakeFactory(t, rt)
|
||||||
|
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if rt.probeReq == nil {
|
||||||
|
t.Fatal("probe request not captured")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||||
|
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.HttpClient = func() (*http.Client, error) {
|
||||||
|
return nil, errors.New("client init failed")
|
||||||
|
}
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if elapsed > 4*time.Second {
|
||||||
|
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||||
|
}
|
||||||
|
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||||
|
// must stay silent and not block.
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
121
cmd/config/init_test.go
Normal file
121
cmd/config/init_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||||
|
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
||||||
|
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
||||||
|
// not for missing user input.
|
||||||
|
|
||||||
|
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
||||||
|
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
||||||
|
assertValidationParam(t, err, "--app-secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
||||||
|
existing := &core.MultiAppConfig{
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "app-default",
|
||||||
|
AppSecret: core.PlainSecret("secret-default"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
||||||
|
assertValidationParam(t, err, "--app-secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
||||||
|
existing := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "missing",
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "app-default",
|
||||||
|
AppSecret: core.PlainSecret("secret-default"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
||||||
|
assertValidationParam(t, err, "--app-secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
||||||
|
existing := &core.MultiAppConfig{
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "app-default",
|
||||||
|
AppSecret: core.PlainSecret("secret-default"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "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: a typed ValidationError must keep ExitValidation rather than
|
||||||
|
// being downgraded to InternalError.
|
||||||
|
|
||||||
|
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||||
|
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
|
||||||
|
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||||
|
WithParam("--app-secret")
|
||||||
|
got := wrapUpdateExistingProfileErr(in)
|
||||||
|
assertValidationParam(t, got, "--app-secret")
|
||||||
|
// Exit code must remain ExitValidation (2), not ExitInternal (5).
|
||||||
|
if code := output.ExitCodeOf(got); code != output.ExitValidation {
|
||||||
|
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
// Must NOT be wrapped as *InternalError.
|
||||||
|
var intErr *errs.InternalError
|
||||||
|
if errors.As(got, &intErr) {
|
||||||
|
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
||||||
|
in := fmt.Errorf("disk full")
|
||||||
|
got := wrapUpdateExistingProfileErr(in)
|
||||||
|
var intErr *errs.InternalError
|
||||||
|
if !errors.As(got, &intErr) {
|
||||||
|
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
|
||||||
|
}
|
||||||
|
if intErr.Subtype != errs.SubtypeSDKError {
|
||||||
|
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertValidationParam asserts err is *ValidationError with the given Param.
|
||||||
|
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
var valErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &valErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
if valErr.Param != wantParam {
|
||||||
|
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
cmd/config/keychain_downgrade.go
Normal file
72
cmd/config/keychain_downgrade.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
|
||||||
|
// the master key to the local file fallback (master.key.file) so subsequent
|
||||||
|
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
|
||||||
|
// where the system Keychain is unreachable.
|
||||||
|
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "keychain-downgrade",
|
||||||
|
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||||
|
Long: `Materialize the master key from the macOS system Keychain into a local file
|
||||||
|
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
|
||||||
|
subsequent reads to that file.
|
||||||
|
|
||||||
|
Intended workflow: run this once from an interactive Terminal session on
|
||||||
|
macOS (where the system Keychain is reachable). After it finishes,
|
||||||
|
sandboxed / automation / CI runs of lark-cli on the same machine will read
|
||||||
|
the master key from the local file and no longer need the OS Keychain.
|
||||||
|
|
||||||
|
This is the supported fix for environments like the Codex sandbox where the
|
||||||
|
system Keychain is blocked. Running keychain-downgrade from inside such a
|
||||||
|
sandbox will itself fail with "keychain access blocked" — that is expected;
|
||||||
|
run it from an interactive macOS session instead.
|
||||||
|
|
||||||
|
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
|
||||||
|
The command is idempotent: re-running it on an already-downgraded install
|
||||||
|
reports "already downgraded" and exits 0.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return configKeychainDowngradeRun(f)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
||||||
|
service := keychain.LarkCliService
|
||||||
|
keyPath := keychain.MasterKeyFilePath(service)
|
||||||
|
|
||||||
|
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||||
|
"keychain downgrade failed: %v", err).
|
||||||
|
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
|
||||||
|
WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case keychain.DowngradeAlreadyDone:
|
||||||
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
|
||||||
|
case keychain.DowngradeUsedKeychainKey:
|
||||||
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
|
||||||
|
case keychain.DowngradeCreatedNewKey:
|
||||||
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
28
cmd/config/keychain_downgrade_other.go
Normal file
28
cmd/config/keychain_downgrade_other.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
|
||||||
|
// `lark-cli config --help` reads the same everywhere. On non-macOS it
|
||||||
|
// refuses with a clear message.
|
||||||
|
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
_ = f
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "keychain-downgrade",
|
||||||
|
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||||
|
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
|||||||
"version": p.Version,
|
"version": p.Version,
|
||||||
"capabilities": p.Capabilities,
|
"capabilities": p.Capabilities,
|
||||||
}
|
}
|
||||||
if p.Rule != nil {
|
if len(p.Rules) > 0 {
|
||||||
entry["rule"] = p.Rule
|
entry["rules"] = p.Rules
|
||||||
}
|
}
|
||||||
entry["hooks"] = map[string]any{
|
entry["hooks"] = map[string]any{
|
||||||
"observers": p.Observers,
|
"observers": p.Observers,
|
||||||
|
|||||||
@@ -59,16 +59,20 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
|||||||
"source_name": sourceName,
|
"source_name": sourceName,
|
||||||
"denied_paths": active.DeniedPaths,
|
"denied_paths": active.DeniedPaths,
|
||||||
}
|
}
|
||||||
if active.Rule != nil {
|
if len(active.Rules) > 0 {
|
||||||
out["rule"] = map[string]any{
|
rules := make([]map[string]any, 0, len(active.Rules))
|
||||||
"name": active.Rule.Name,
|
for _, r := range active.Rules {
|
||||||
"description": active.Rule.Description,
|
rules = append(rules, map[string]any{
|
||||||
"allow": active.Rule.Allow,
|
"name": r.Name,
|
||||||
"deny": active.Rule.Deny,
|
"description": r.Description,
|
||||||
"max_risk": active.Rule.MaxRisk,
|
"allow": r.Allow,
|
||||||
"identities": active.Rule.Identities,
|
"deny": r.Deny,
|
||||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
"max_risk": r.MaxRisk,
|
||||||
|
"identities": r.Identities,
|
||||||
|
"allow_unannotated": r.AllowUnannotated,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
out["rules"] = rules
|
||||||
}
|
}
|
||||||
output.PrintJson(f.IOStreams.Out, out)
|
output.PrintJson(f.IOStreams.Out, out)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
MaxRisk: "read",
|
MaxRisk: "read",
|
||||||
}
|
}
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: rule,
|
Rules: []*platform.Rule{rule},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourcePlugin,
|
Kind: cmdpolicy.SourcePlugin,
|
||||||
Name: "secaudit",
|
Name: "secaudit",
|
||||||
@@ -83,12 +83,16 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
if got["denied_paths"] != float64(42) {
|
if got["denied_paths"] != float64(42) {
|
||||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||||
}
|
}
|
||||||
ruleMap, ok := got["rule"].(map[string]any)
|
rulesAny, ok := got["rules"].([]any)
|
||||||
|
if !ok || len(rulesAny) != 1 {
|
||||||
|
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
|
||||||
|
}
|
||||||
|
ruleMap, ok := rulesAny[0].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("rule field missing or wrong type")
|
t.Fatalf("rules[0] wrong type")
|
||||||
}
|
}
|
||||||
if ruleMap["name"] != "secaudit" {
|
if ruleMap["name"] != "secaudit" {
|
||||||
t.Errorf("rule.name = %v", ruleMap["name"])
|
t.Errorf("rules[0].name = %v", ruleMap["name"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
|||||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourceYAML,
|
Kind: cmdpolicy.SourceYAML,
|
||||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -42,14 +43,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
|||||||
|
|
||||||
config, err := core.LoadMultiAppConfig()
|
config, err := core.LoadMultiAppConfig()
|
||||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||||
return output.ErrValidation("not configured yet")
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
// 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.
|
// existing config can still be retried instead of ending up half-removed.
|
||||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up keychain entries for all apps after config is cleared.
|
// Clean up keychain entries for all apps after config is cleared.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -47,14 +48,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return core.NotConfiguredError()
|
return core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if config == nil || len(config.Apps) == 0 {
|
if config == nil || len(config.Apps) == 0 {
|
||||||
return core.NotConfiguredError()
|
return core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||||
}
|
}
|
||||||
users := "(no logged-in users)"
|
users := "(no logged-in users)"
|
||||||
if len(app.Users) > 0 {
|
if len(app.Users) > 0 {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`,
|
|||||||
|
|
||||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||||
if global {
|
if global {
|
||||||
return output.ErrValidation("--reset cannot be used with --global")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
||||||
}
|
}
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
||||||
}
|
}
|
||||||
app.StrictMode = nil
|
app.StrictMode = nil
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||||
return nil
|
return nil
|
||||||
@@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
|||||||
switch mode {
|
switch mode {
|
||||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||||
default:
|
default:
|
||||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||||
@@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
|
|||||||
// underlying problem is still visible.
|
// underlying problem is still visible.
|
||||||
msg, hint := err.Error(), ""
|
msg, hint := err.Error(), ""
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
|
|||||||
cfg, err := f.Config()
|
cfg, err := f.Config()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hint := ""
|
hint := ""
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if errors.As(err, &cfgErr) {
|
if errors.As(err, &cfgErr) {
|
||||||
hint = cfgErr.Hint
|
hint = cfgErr.Hint
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{}
|
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||||
|
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||||
|
httpClient := transport.NewHTTPClient(0)
|
||||||
mcpURL := ep.MCP + "/mcp"
|
mcpURL := ep.MCP + "/mcp"
|
||||||
|
|
||||||
type probeResult struct {
|
type probeResult struct {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -23,12 +23,8 @@ import (
|
|||||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||||
// a need_user_authorization signal AND the current command declares scopes
|
// a need_user_authorization signal AND the current command declares scopes
|
||||||
// locally (via shortcut registration or service-method metadata).
|
// locally (via shortcut registration or service-method metadata). Existing
|
||||||
//
|
// Hint text is preserved; scopes are appended on a new line.
|
||||||
// Stage-1: this typed path is dormant — no production code returns a typed
|
|
||||||
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
|
|
||||||
// in without re-architecting. The active stage-1 path is
|
|
||||||
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
|
|
||||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||||
if err == nil || f == nil {
|
if err == nil || f == nil {
|
||||||
return
|
return
|
||||||
@@ -52,34 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
|||||||
authErr.Hint += "\n" + scopeHint
|
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. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
|
||||||
// envelope path until per-domain stage-2 typed migration.
|
|
||||||
//
|
|
||||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
|
|
||||||
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
|
|
||||||
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
|
|
||||||
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
|
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||||
// current command for the resolved identity, checking shortcuts first and then
|
// current command for the resolved identity, checking shortcuts first and then
|
||||||
// service methods from local registry metadata.
|
// service methods from local registry metadata.
|
||||||
@@ -124,78 +92,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||||
// service/resource/method command from the embedded from_meta registry.
|
// service/resource/method command. It reconstructs the catalog path from the
|
||||||
|
// command ancestry and resolves it through the same navigation Module the
|
||||||
|
// command tree is built from (apicatalog), so it stays correct for nested
|
||||||
|
// resources instead of hard-coding a root->service->resource->method depth.
|
||||||
|
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||||
|
// target and yield no scopes.
|
||||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||||
// Service-method scope lookup only applies to commands mounted as
|
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||||
// root -> service -> resource -> method. Non-resource/method commands
|
|
||||||
// intentionally return no scopes here so auth-hint enrichment does not
|
|
||||||
// change runtime semantics for other command shapes.
|
|
||||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(cmd.Name(), "+") {
|
path := commandCatalogPath(cmd)
|
||||||
|
if len(path) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||||
service := cmd.Parent().Parent().Name()
|
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||||
resource := cmd.Parent().Name()
|
|
||||||
method := cmd.Name()
|
|
||||||
|
|
||||||
spec := registry.LoadFromMeta(service)
|
|
||||||
if spec == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resources, _ := spec["resources"].(map[string]interface{})
|
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||||
resMap, _ := resources[resource].(map[string]interface{})
|
|
||||||
if resMap == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
methods, _ := resMap["methods"].(map[string]interface{})
|
|
||||||
methodMap, _ := methods[method].(map[string]interface{})
|
|
||||||
if methodMap == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return declaredScopesForMethod(methodMap, identity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||||
// resolves the single recommended scope from the method's scopes list.
|
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||||
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
// the service command tree's construction, so any depth (flat or nested)
|
||||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
// round-trips through apicatalog.Resolve.
|
||||||
return interfaceStrings(requiredRaw)
|
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||||
|
var path []string
|
||||||
|
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||||
|
path = append([]string{c.Name()}, path...)
|
||||||
}
|
}
|
||||||
|
return path
|
||||||
rawScopes, _ := method["scopes"].([]interface{})
|
|
||||||
if len(rawScopes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
|
||||||
if recommended == "" {
|
|
||||||
for _, raw := range rawScopes {
|
|
||||||
if scope, ok := raw.(string); ok && scope != "" {
|
|
||||||
recommended = scope
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if recommended == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{recommended}
|
|
||||||
}
|
|
||||||
|
|
||||||
// interfaceStrings converts a []interface{} containing strings into a compact
|
|
||||||
// []string, skipping empty or non-string values.
|
|
||||||
func interfaceStrings(values []interface{}) []string {
|
|
||||||
scopes := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
scope, ok := value.(string)
|
|
||||||
if !ok || scope == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
scopes = append(scopes, scope)
|
|
||||||
}
|
|
||||||
return scopes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"regexp"
|
"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"'<>]+`)
|
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.
|
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
logger, err := bus.SetupBusLogger(eventsDir)
|
logger, err := bus.SetupBusLogger(eventsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"set up bus logger: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := transport.New()
|
tr := transport.New()
|
||||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return b.Run(ctx)
|
if err := b.Run(ctx); err != nil {
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"event bus daemon exited: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||||
|
// when its log directory cannot be created (the error is only visible in the
|
||||||
|
// forked process's captured stderr / bus.log).
|
||||||
|
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
// Block the events/ root with a regular file so MkdirAll fails.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
cmd := NewCmdBus(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected logger setup error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,21 +4,117 @@
|
|||||||
package event
|
package event
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||||
host := core.ResolveEndpoints(brand).Open
|
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||||
host, appID, strings.Join(scopes, ","))
|
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.
|
type AddonsScopes struct {
|
||||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
Tenant []string `json:"tenant"`
|
||||||
host := core.ResolveEndpoints(brand).Open
|
User []string `json:"user"`
|
||||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
}
|
||||||
|
|
||||||
|
type AddonsEvents struct {
|
||||||
|
Items AddonsEventItems `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddonsEventItems struct {
|
||||||
|
Tenant []string `json:"tenant"`
|
||||||
|
User []string `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddonsCallbacks struct {
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||||
|
func encodeAddons(a ManifestAddons) (string, error) {
|
||||||
|
raw, err := json.Marshal(a)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gw := gzip.NewWriter(&buf)
|
||||||
|
if _, err := gw.Write(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := gw.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||||
|
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||||
|
encoded, err := encodeAddons(a)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
host := core.ResolveEndpoints(brand).Open
|
||||||
|
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||||
|
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||||
|
host := core.ResolveEndpoints(brand).Open
|
||||||
|
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||||
|
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||||
|
url, err := consoleAddonsURL(brand, appID, a)
|
||||||
|
if err != nil {
|
||||||
|
return consoleLandingURL(brand, appID)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||||
|
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||||
|
// the addons spec treats a missing tenant/user as an empty array.
|
||||||
|
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||||
|
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||||
|
if identity.IsBot() {
|
||||||
|
s.Tenant = missing
|
||||||
|
} else {
|
||||||
|
s.User = missing
|
||||||
|
}
|
||||||
|
return ManifestAddons{Scopes: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||||
|
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||||
|
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||||
|
if subType == eventlib.SubTypeCallback {
|
||||||
|
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||||
|
}
|
||||||
|
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||||
|
if identity.IsBot() {
|
||||||
|
ev.Items.Tenant = missing
|
||||||
|
} else {
|
||||||
|
ev.Items.User = missing
|
||||||
|
}
|
||||||
|
return ManifestAddons{Events: ev}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,33 +4,109 @@
|
|||||||
package event
|
package event
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
t.Helper()
|
||||||
"im:message:readonly",
|
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
"im:message.group_at_msg",
|
if err != nil {
|
||||||
})
|
t.Fatalf("base64url decode: %v", err)
|
||||||
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 {
|
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
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) {
|
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
if err != nil {
|
||||||
if got != want {
|
t.Fatalf("url: %v", err)
|
||||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
}
|
||||||
|
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||||
|
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||||
|
if !strings.HasPrefix(url, prefix) {
|
||||||
|
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||||
|
}
|
||||||
|
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||||
|
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||||
|
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||||
t.Errorf("unexpected url: %s", got)
|
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||||
|
}
|
||||||
|
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||||
|
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||||
|
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||||
|
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||||
|
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||||
|
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||||
|
}
|
||||||
|
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||||
|
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||||
|
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||||
|
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||||
|
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||||
|
cases := []ManifestAddons{
|
||||||
|
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||||
|
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||||
|
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||||
|
}
|
||||||
|
for i, a := range cases {
|
||||||
|
raw, err := json.Marshal(a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("case %d marshal: %v", i, err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(raw, []byte("null")) {
|
||||||
|
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/appmeta"
|
"github.com/larksuite/cli/internal/appmeta"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
|||||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
|||||||
|
|
||||||
if o.jqExpr != "" {
|
if o.jqExpr != "" {
|
||||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||||
output.ExitValidation, "validation",
|
WithParam("--jq").
|
||||||
err.Error(),
|
WithCause(err).
|
||||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
|||||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
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{
|
pf := &preflightCtx{
|
||||||
factory: f,
|
factory: f,
|
||||||
appID: cfg.AppID,
|
appID: cfg.AppID,
|
||||||
brand: cfg.Brand,
|
brand: cfg.Brand,
|
||||||
eventKey: eventKey,
|
eventKey: eventKey,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
keyDef: keyDef,
|
keyDef: keyDef,
|
||||||
appVer: appVer,
|
appVer: appVer,
|
||||||
|
subscribedCallbacks: subscribedCallbacks,
|
||||||
}
|
}
|
||||||
if err := preflightEventTypes(pf); err != nil {
|
if err := preflightEventTypes(pf); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -184,8 +198,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
|||||||
errOut = io.Discard
|
errOut = io.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||||
if !f.IOStreams.IsTerminal {
|
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||||
|
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +243,9 @@ type preflightCtx struct {
|
|||||||
identity core.Identity
|
identity core.Identity
|
||||||
keyDef *eventlib.KeyDefinition
|
keyDef *eventlib.KeyDefinition
|
||||||
appVer *appmeta.AppVersion
|
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).
|
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||||
@@ -260,63 +278,87 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||||
output.ExitAuth, "auth",
|
"missing required scopes for EventKey %s (as %s): %s",
|
||||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
WithIdentity(string(pf.identity)).
|
||||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
WithMissingScopes(missing...).
|
||||||
)
|
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||||
|
// the tenant token carries them. User: the scan link only updates the app
|
||||||
|
// manifest — the user's own token still lacks the scopes until it is
|
||||||
|
// re-authorized — so direct the user to re-login instead.
|
||||||
|
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||||
if identity.IsBot() {
|
if identity.IsBot() {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||||
"grant these scopes and publish a new app version at: %s",
|
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||||
consoleScopeGrantURL(brand, appID, missing),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(
|
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.",
|
"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 {
|
func preflightEventTypes(pf *preflightCtx) error {
|
||||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
|
||||||
for _, t := range pf.appVer.EventTypes {
|
var subscribed []string
|
||||||
subscribed[t] = true
|
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
|
var missing []string
|
||||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||||
if !subscribed[t] {
|
if !have[t] {
|
||||||
missing = append(missing, t)
|
missing = append(missing, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
|
||||||
output.ExitValidation, "validation",
|
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
"EventKey %s requires %s not subscribed in console: %s",
|
||||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||||
func sanitizeOutputDir(dir string) (string, error) {
|
func sanitizeOutputDir(dir string) (string, error) {
|
||||||
if strings.HasPrefix(dir, "~") {
|
if strings.HasPrefix(dir, "~") {
|
||||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||||
|
WithParam("--output-dir").
|
||||||
|
WithCause(errOutputDirTilde)
|
||||||
}
|
}
|
||||||
safe, err := validate.SafeOutputPath(dir)
|
safe, err := validate.SafeOutputPath(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||||
|
WithParam("--output-dir").
|
||||||
|
WithCause(errOutputDirUnsafe)
|
||||||
}
|
}
|
||||||
return safe, nil
|
return safe, nil
|
||||||
}
|
}
|
||||||
@@ -328,22 +370,25 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
|||||||
}
|
}
|
||||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
|
"resolve tenant access token: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if result == nil || result.Token == "" {
|
if result == nil || result.Token == "" {
|
||||||
return "", output.ErrWithHint(
|
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
output.ExitAuth, "auth",
|
"no tenant access token available for app %s", appID).
|
||||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return result.Token, nil
|
return result.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||||
var (
|
var (
|
||||||
errInvalidParamFormat = errors.New("invalid --param format")
|
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
|
||||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
|
||||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseParams(raw []string) (map[string]string, error) {
|
func parseParams(raw []string) (map[string]string, error) {
|
||||||
@@ -351,7 +396,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
|||||||
for _, kv := range raw {
|
for _, kv := range raw {
|
||||||
k, v, ok := strings.Cut(kv, "=")
|
k, v, ok := strings.Cut(kv, "=")
|
||||||
if !ok || k == "" {
|
if !ok || k == "" {
|
||||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||||
|
WithParam("--param").
|
||||||
|
WithCause(errInvalidParamFormat)
|
||||||
}
|
}
|
||||||
m[k] = v
|
m[k] = v
|
||||||
}
|
}
|
||||||
@@ -370,3 +418,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||||
|
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||||
|
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
|||||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isTerminal bool
|
||||||
|
maxEvents int
|
||||||
|
timeout time.Duration
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "terminal",
|
||||||
|
isTerminal: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal unbounded",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal negative max events is unbounded",
|
||||||
|
maxEvents: -1,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal negative timeout is unbounded",
|
||||||
|
timeout: -1 * time.Second,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal max events bounded",
|
||||||
|
maxEvents: 1,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal timeout bounded",
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal both bounds positive",
|
||||||
|
maxEvents: 1,
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal bounded max events with negative timeout",
|
||||||
|
maxEvents: 1,
|
||||||
|
timeout: -1 * time.Second,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal bounded timeout with negative max events",
|
||||||
|
maxEvents: -1,
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
package event
|
package event
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseParams(t *testing.T) {
|
func TestParseParams(t *testing.T) {
|
||||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
|||||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||||
}
|
}
|
||||||
|
assertInvalidArgumentParam(t, err, "--param")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emptyTokenResolver resolves to a result that carries no token.
|
||||||
|
type emptyTokenResolver struct{}
|
||||||
|
|
||||||
|
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return &credential.TokenResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingTokenResolver fails outright with an untyped error.
|
||||||
|
type failingTokenResolver struct{}
|
||||||
|
|
||||||
|
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return nil, errors.New("backend unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||||
|
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||||
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
var malformed *credential.MalformedTokenResultError
|
||||||
|
if !errors.As(err, &malformed) {
|
||||||
|
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||||
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
if errors.Unwrap(err) == nil {
|
||||||
|
t.Error("resolver failure should preserve its cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||||
|
// subtype invalid_argument naming the given flag in its param field.
|
||||||
|
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||||
|
t.Helper()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
if ve.Param != param {
|
||||||
|
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeOutputDir(t *testing.T) {
|
func TestSanitizeOutputDir(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
|||||||
if !errors.Is(err, tc.wantSentry) {
|
if !errors.Is(err, tc.wantSentry) {
|
||||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||||
}
|
}
|
||||||
|
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 2,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||||
|
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB column header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "alice") {
|
||||||
|
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "bob") {
|
||||||
|
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 1,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "-") {
|
||||||
|
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 1,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "-") {
|
||||||
|
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := writeStatusJSON(&buf, []appStatus{
|
if err := writeStatusJSON(&buf, []appStatus{
|
||||||
@@ -197,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("flag on + orphan → expected error, got 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 {
|
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func errorAs(err error, target interface{}) bool {
|
func errorAs(err error, target interface{}) bool {
|
||||||
if e, ok := err.(*output.ExitError); ok {
|
if e, ok := err.(*output.BareError); ok {
|
||||||
if t, ok := target.(**output.ExitError); ok {
|
if t, ok := target.(**output.BareError); ok {
|
||||||
*t = e
|
*t = e
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/appmeta"
|
"github.com/larksuite/cli/internal/appmeta"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||||
t.Errorf("error should name the missing event type, got: %v", err)
|
t.Errorf("error should name the missing event type, got: %v", err)
|
||||||
}
|
}
|
||||||
var exit *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exit) {
|
if !ok {
|
||||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exit.Code != output.ExitValidation {
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||||
}
|
}
|
||||||
if exit.Detail == nil {
|
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||||
t.Fatal("expected Detail with hint")
|
if !strings.Contains(p.Hint, wantURL) {
|
||||||
}
|
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
|
||||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
|
||||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,21 +143,22 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||||
t.Errorf("error should name missing scope, got: %v", err)
|
t.Errorf("error should name missing scope, got: %v", err)
|
||||||
}
|
}
|
||||||
var exit *output.ExitError
|
var permErr *errs.PermissionError
|
||||||
if !errors.As(err, &exit) {
|
if !errors.As(err, &permErr) {
|
||||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exit.Code != output.ExitAuth {
|
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||||
|
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||||
}
|
}
|
||||||
if exit.Detail == nil {
|
wantMissing := []string{"im:message.group_at_msg"}
|
||||||
t.Fatal("expected Detail with hint, got nil Detail")
|
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||||
|
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||||
}
|
}
|
||||||
hint := exit.Detail.Hint
|
hint := permErr.Hint
|
||||||
wantSubstrings := []string{
|
wantSubstrings := []string{
|
||||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
"grant these scopes by scanning: ",
|
||||||
"im:message.group_at_msg",
|
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||||
"token_type=tenant",
|
|
||||||
}
|
}
|
||||||
for _, want := range wantSubstrings {
|
for _, want := range wantSubstrings {
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
|||||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||||
|
pf := &preflightCtx{
|
||||||
|
appID: "cli_x",
|
||||||
|
brand: core.BrandFeishu,
|
||||||
|
eventKey: "test.cb",
|
||||||
|
identity: core.AsBot,
|
||||||
|
subscribedCallbacks: []string{"profile.view.get"},
|
||||||
|
keyDef: &eventlib.KeyDefinition{
|
||||||
|
Key: "test.cb",
|
||||||
|
SubscriptionType: eventlib.SubTypeCallback,
|
||||||
|
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := preflightEventTypes(pf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing callback")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||||
|
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||||
|
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||||
|
pf := &preflightCtx{
|
||||||
|
appID: "cli_x",
|
||||||
|
brand: core.BrandFeishu,
|
||||||
|
eventKey: "test.cb",
|
||||||
|
identity: core.AsBot,
|
||||||
|
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||||
|
keyDef: &eventlib.KeyDefinition{
|
||||||
|
Key: "test.cb",
|
||||||
|
SubscriptionType: eventlib.SubTypeCallback,
|
||||||
|
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := preflightEventTypes(pf); err != nil {
|
||||||
|
t.Errorf("expected skip (nil), got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||||
|
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||||
|
// console state: a required callback IS missing and must be reported,
|
||||||
|
// not skipped as a weak dependency.
|
||||||
|
pf := &preflightCtx{
|
||||||
|
appID: "cli_x",
|
||||||
|
brand: core.BrandFeishu,
|
||||||
|
eventKey: "test.cb",
|
||||||
|
identity: core.AsBot,
|
||||||
|
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||||
|
keyDef: &eventlib.KeyDefinition{
|
||||||
|
Key: "test.cb",
|
||||||
|
SubscriptionType: eventlib.SubTypeCallback,
|
||||||
|
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := preflightEventTypes(pf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing callback when none are subscribed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||||
|
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||||
|
pf := &preflightCtx{
|
||||||
|
appID: "cli_x",
|
||||||
|
brand: core.BrandFeishu,
|
||||||
|
eventKey: "test.cb",
|
||||||
|
identity: core.AsBot,
|
||||||
|
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||||
|
keyDef: &eventlib.KeyDefinition{
|
||||||
|
Key: "test.cb",
|
||||||
|
SubscriptionType: eventlib.SubTypeCallback,
|
||||||
|
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := preflightEventTypes(pf); err != nil {
|
||||||
|
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||||
|
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||||
|
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||||
|
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||||
|
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||||
|
}
|
||||||
|
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||||
|
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||||
|
if !strings.Contains(user, "auth login --scope") {
|
||||||
|
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||||
|
}
|
||||||
|
if strings.Contains(user, "/page/launcher") {
|
||||||
|
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ package event
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
|||||||
As: r.accessIdentity,
|
As: r.accessIdentity,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||||
|
"api %s %s: %s", method, path, err).WithCause(err)
|
||||||
}
|
}
|
||||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||||
ct := resp.Header.Get("Content-Type")
|
ct := resp.Header.Get("Content-Type")
|
||||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
|||||||
if len(body) > maxBodyEcho {
|
if len(body) > maxBodyEcho {
|
||||||
body = body[:maxBodyEcho] + "…(truncated)"
|
body = body[:maxBodyEcho] + "…(truncated)"
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
if resp.StatusCode >= 500 {
|
||||||
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||||
|
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||||
|
}
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||||
|
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
result, err := client.ParseJSONResponse(resp)
|
result, err := client.ParseJSONResponse(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||||
|
"api %s %s: %s", method, path, err).WithCause(err)
|
||||||
}
|
}
|
||||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||||
return json.RawMessage(resp.RawBody), apiErr
|
return json.RawMessage(resp.RawBody), apiErr
|
||||||
|
|||||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/client"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||||
|
type staticTokenResolver struct{}
|
||||||
|
|
||||||
|
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return &credential.TokenResult{Token: "test-token"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||||
|
type stubRoundTripper struct {
|
||||||
|
respond func(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||||
|
|
||||||
|
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||||
|
sdk := lark.NewClient("test-app", "test-secret",
|
||||||
|
lark.WithEnableTokenCache(false),
|
||||||
|
lark.WithLogLevel(larkcore.LogLevelError),
|
||||||
|
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||||
|
)
|
||||||
|
return &consumeRuntime{
|
||||||
|
client: &client.APIClient{
|
||||||
|
SDK: sdk,
|
||||||
|
ErrOut: io.Discard,
|
||||||
|
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||||
|
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||||
|
},
|
||||||
|
accessIdentity: core.AsBot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||||
|
return func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: http.Header{"Content-Type": []string{contentType}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != category || p.Subtype != subtype {
|
||||||
|
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||||
|
if !strings.Contains(err.Error(), "returned 404") {
|
||||||
|
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||||
|
long := strings.Repeat("x", 300)
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||||
|
p, _ := errs.ProblemOf(err)
|
||||||
|
if !p.Retryable {
|
||||||
|
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||||
|
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryNetwork {
|
||||||
|
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||||
|
`{"code":99991663,"msg":"app not found"}`)})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if _, ok := errs.ProblemOf(err); !ok {
|
||||||
|
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||||
|
`{"code":0,"data":{"ok":true}}`)})
|
||||||
|
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"code":0`) {
|
||||||
|
t.Errorf("raw body should pass through, got: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/event/schemas"
|
"github.com/larksuite/cli/internal/event/schemas"
|
||||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
|||||||
if len(def.Schema.FieldOverrides) > 0 {
|
if len(def.Schema.FieldOverrides) > 0 {
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||||
out, err := json.Marshal(parsed)
|
out, err := json.Marshal(parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
return out, orphans, nil
|
return out, orphans, nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
|||||||
copy(buf, s.Raw)
|
copy(buf, s.Raw)
|
||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
if len(def.Params) > 0 {
|
if len(def.Params) > 0 {
|
||||||
fmt.Fprintf(out, "\nParameters:\n")
|
fmt.Fprintf(out, "\nParameters:\n")
|
||||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||||
for _, p := range def.Params {
|
for _, p := range def.Params {
|
||||||
required := "no"
|
required := "no"
|
||||||
if p.Required {
|
if p.Required {
|
||||||
required = "yes"
|
required = "yes"
|
||||||
}
|
}
|
||||||
|
subKey := "no"
|
||||||
|
if p.SubscriptionKey {
|
||||||
|
subKey = "yes"
|
||||||
|
}
|
||||||
defaultVal := p.Default
|
defaultVal := p.Default
|
||||||
if defaultVal == "" {
|
if defaultVal == "" {
|
||||||
defaultVal = "-"
|
defaultVal = "-"
|
||||||
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = "-"
|
desc = "-"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
|
|
||||||
resolved, _, err := resolveSchemaJSON(def)
|
resolved, _, err := resolveSchemaJSON(def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
@@ -95,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||||
|
const syntheticKey = "test.evt_sub"
|
||||||
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|
||||||
|
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||||
|
Key: syntheticKey,
|
||||||
|
EventType: syntheticKey,
|
||||||
|
Params: []eventlib.ParamDef{
|
||||||
|
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||||
|
{Name: "folders", Description: "filter only"},
|
||||||
|
},
|
||||||
|
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||||
|
})
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||||
|
t.Fatalf("runSchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "SUB-KEY") {
|
||||||
|
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the mailbox row and verify "yes" is present
|
||||||
|
var mailboxRow string
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||||
|
mailboxRow = ln
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(mailboxRow, "yes") {
|
||||||
|
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the folders row and verify "no" is present
|
||||||
|
var foldersRow string
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||||
|
foldersRow = ln
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(foldersRow, "no") {
|
||||||
|
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||||
|
const syntheticKey = "test.evt_json"
|
||||||
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|
||||||
|
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||||
|
Key: syntheticKey,
|
||||||
|
EventType: syntheticKey,
|
||||||
|
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||||
|
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||||
|
})
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||||
|
t.Fatalf("runSchema json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||||
|
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `true`) {
|
||||||
|
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||||
const syntheticKey = "t.custom.overlay"
|
const syntheticKey = "t.custom.overlay"
|
||||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
@@ -129,3 +203,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
|||||||
t.Errorf("overlay format = %v, want open_id", got)
|
t.Errorf("overlay format = %v, want open_id", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||||
|
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||||
|
def := &eventlib.KeyDefinition{
|
||||||
|
Key: "synthetic.invalid.base",
|
||||||
|
Schema: eventlib.SchemaDef{
|
||||||
|
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||||
|
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, _, err := resolveSchemaJSON(def)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unparsable base schema")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
|||||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||||
if len(s.Consumers) > 0 {
|
if len(s.Consumers) > 0 {
|
||||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||||
rows := make([][]string, 0, len(s.Consumers))
|
rows := make([][]string, 0, len(s.Consumers))
|
||||||
for _, c := range s.Consumers {
|
for _, c := range s.Consumers {
|
||||||
|
subDisplay := "-"
|
||||||
|
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||||
|
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||||
|
}
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("pid=%d", c.PID),
|
fmt.Sprintf("pid=%d", c.PID),
|
||||||
c.EventKey,
|
c.EventKey,
|
||||||
|
subDisplay,
|
||||||
fmt.Sprintf("%d", c.Received),
|
fmt.Sprintf("%d", c.Received),
|
||||||
fmt.Sprintf("%d", c.Dropped),
|
fmt.Sprintf("%d", c.Dropped),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var bareErr *output.BareError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &bareErr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
t.Fatalf("expected *output.BareError, got %T", err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
if bareErr.Code != output.ExitValidation {
|
||||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSuggestions = 3
|
const maxSuggestions = 3
|
||||||
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
|
|||||||
hits = append(hits, match{def.Key, 0})
|
hits = append(hits, match{def.Key, 0})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d := levenshtein(input, def.Key); d <= threshold {
|
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||||
hits = append(hits, match{def.Key, d})
|
hits = append(hits, match{def.Key, d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,40 +64,6 @@ func unknownEventKeyErr(key string) error {
|
|||||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||||
output.ExitValidation, "validation",
|
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||||
msg,
|
|
||||||
"Run 'lark-cli event list' to see available keys.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// levenshtein computes classic edit distance (two-row DP).
|
|
||||||
func levenshtein(a, b string) int {
|
|
||||||
if a == b {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
ra, rb := []rune(a), []rune(b)
|
|
||||||
if len(ra) == 0 {
|
|
||||||
return len(rb)
|
|
||||||
}
|
|
||||||
if len(rb) == 0 {
|
|
||||||
return len(ra)
|
|
||||||
}
|
|
||||||
prev := make([]int, len(rb)+1)
|
|
||||||
curr := make([]int, len(rb)+1)
|
|
||||||
for j := range prev {
|
|
||||||
prev[j] = j
|
|
||||||
}
|
|
||||||
for i := 1; i <= len(ra); i++ {
|
|
||||||
curr[0] = i
|
|
||||||
for j := 1; j <= len(rb); j++ {
|
|
||||||
cost := 1
|
|
||||||
if ra[i-1] == rb[j-1] {
|
|
||||||
cost = 0
|
|
||||||
}
|
|
||||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
|
||||||
}
|
|
||||||
prev, curr = curr, prev
|
|
||||||
}
|
|
||||||
return prev[len(rb)]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,6 @@ import (
|
|||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLevenshtein(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
a, b string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"", "", 0},
|
|
||||||
{"a", "", 1},
|
|
||||||
{"", "abc", 3},
|
|
||||||
{"kitten", "kitten", 0},
|
|
||||||
{"kitten", "sitten", 1},
|
|
||||||
{"kitten", "sitting", 3},
|
|
||||||
{"飞书", "飞书", 0},
|
|
||||||
{"飞书", "飞s", 1},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
|
||||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuggestEventKeys(t *testing.T) {
|
func TestSuggestEventKeys(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
104
cmd/flag_suggest_test.go
Normal file
104
cmd/flag_suggest_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnknownFlagName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"unknown flag: --query", "query", true},
|
||||||
|
{"unknown flag: --with-styles", "with-styles", true},
|
||||||
|
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||||
|
{"flag needs an argument: --find", "", false},
|
||||||
|
{`invalid argument "x" for "--count"`, "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
name, ok := unknownFlagName(errors.New(c.in))
|
||||||
|
if name != c.name || ok != c.ok {
|
||||||
|
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||||
|
c := &cobra.Command{Use: "demo"}
|
||||||
|
c.Flags().String("range", "", "")
|
||||||
|
c.Flags().String("find", "", "")
|
||||||
|
c.Flags().Bool("dry-run", false, "")
|
||||||
|
|
||||||
|
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
// 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 verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||||
|
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||||
|
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
if verr.Param != "" || len(verr.Params) != 0 {
|
||||||
|
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||||
|
}
|
||||||
|
if strings.Contains(verr.Hint, "did you mean") {
|
||||||
|
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// composePendingNotice must surface a deprecated-command alias under the
|
||||||
|
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||||
|
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||||
|
// without ever reading --help.
|
||||||
|
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
|
||||||
|
deprecation.SetPending(&deprecation.Notice{
|
||||||
|
Command: "+read",
|
||||||
|
Replacement: "+cells-get",
|
||||||
|
Skill: "lark-sheets",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := composePendingNotice()
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||||
|
}
|
||||||
|
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||||
|
}
|
||||||
|
if entry["command"] != "+read" {
|
||||||
|
t.Errorf("command = %v, want +read", entry["command"])
|
||||||
|
}
|
||||||
|
if entry["replacement"] != "+cells-get" {
|
||||||
|
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||||
|
}
|
||||||
|
if entry["skill"] != "lark-sheets" {
|
||||||
|
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||||
|
}
|
||||||
|
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||||
|
t.Errorf("message missing skill-update hint: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||||
|
// emitted on a clean run.
|
||||||
|
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
deprecation.SetPending(nil)
|
||||||
|
|
||||||
|
if got := composePendingNotice(); got != nil {
|
||||||
|
// update/skills pending are process-global; only assert the absence of
|
||||||
|
// our own key to stay robust against unrelated pending state.
|
||||||
|
if _, ok := got["deprecated_command"]; ok {
|
||||||
|
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,47 +36,71 @@ const userPolicyFileName = "policy.yml"
|
|||||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||||
// the InstallAll phase; nil/empty is fine.
|
// the InstallAll phase; nil/empty is fine.
|
||||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||||
yamlPath, err := userPolicyPath()
|
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||||
if err != nil {
|
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||||
// No user home dir means we cannot locate the policy. Treat
|
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||||
// the same as "file missing": no pruning, no error. This keeps
|
// error once a plugin is present, so reading a malformed yaml here
|
||||||
// non-interactive CI environments (no HOME set) running.
|
// would let an unrelated broken file on the user's machine abort a
|
||||||
yamlPath = ""
|
// plugin-governed binary -- exactly the file the plugin is supposed
|
||||||
|
// to shadow. Skipping the read keeps the shadow contract honest.
|
||||||
|
var (
|
||||||
|
yamlRules []*platform.Rule
|
||||||
|
yamlPath string
|
||||||
|
)
|
||||||
|
if len(pluginRules) == 0 {
|
||||||
|
p, perr := userPolicyPath()
|
||||||
|
if perr != nil {
|
||||||
|
// No user home dir means we cannot locate the policy. Treat
|
||||||
|
// the same as "file missing": no pruning, no error. This keeps
|
||||||
|
// non-interactive CI environments (no HOME set) running.
|
||||||
|
p = ""
|
||||||
|
}
|
||||||
|
yamlPath = p
|
||||||
|
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||||
|
if lerr != nil {
|
||||||
|
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||||
|
// continue), but the active-policy snapshot is process-global
|
||||||
|
// and may still carry data from a previous build in long-lived
|
||||||
|
// embedders / tests. Clear it explicitly so `config policy
|
||||||
|
// show` reports "no policy" instead of a stale rule that
|
||||||
|
// doesn't reflect the current command tree.
|
||||||
|
cmdpolicy.SetActive(nil)
|
||||||
|
return lerr
|
||||||
|
}
|
||||||
|
yamlRules = loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
if err != nil {
|
|
||||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
|
||||||
// continue), but the active-policy snapshot is process-global
|
|
||||||
// and may still carry data from a previous build in long-lived
|
|
||||||
// embedders / tests. Clear it explicitly so `config policy
|
|
||||||
// show` reports "no policy" instead of a stale rule that
|
|
||||||
// doesn't reflect the current command tree.
|
|
||||||
cmdpolicy.SetActive(nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
|
||||||
PluginRules: pluginRules,
|
PluginRules: pluginRules,
|
||||||
YAMLRule: yamlRule,
|
YAMLRules: yamlRules,
|
||||||
YAMLPath: yamlPath,
|
YAMLPath: yamlPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rule == nil {
|
if len(rules) == 0 {
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := cmdpolicy.New(rule)
|
// RuleName attributes a denial to a specific rule in the envelope.
|
||||||
|
// With a single rule that is unambiguous and preserves the legacy
|
||||||
|
// envelope verbatim; with several rules a denial means "no rule
|
||||||
|
// granted it", which has no single owner, so the field is left empty
|
||||||
|
// and reason_code=no_matching_rule carries the meaning instead.
|
||||||
|
ruleName := ""
|
||||||
|
if len(rules) == 1 {
|
||||||
|
ruleName = rules[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := cmdpolicy.NewSet(rules)
|
||||||
decisions := engine.EvaluateAll(rootCmd)
|
decisions := engine.EvaluateAll(rootCmd)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||||
cmdpolicy.Apply(rootCmd, denied)
|
cmdpolicy.Apply(rootCmd, denied)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: rule,
|
Rules: rules,
|
||||||
Source: source,
|
Source: source,
|
||||||
DeniedPaths: len(denied),
|
DeniedPaths: len(denied),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -100,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
|
// 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.
|
// untouched.
|
||||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||||
cfgDir := tmpHome(t)
|
cfgDir := tmpHome(t)
|
||||||
@@ -125,13 +129,27 @@ max_risk: write
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("+delete-doc RunE should return an error")
|
t.Fatalf("+delete-doc RunE should return an error")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
}
|
||||||
|
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).
|
// im/+send must be denied (domain not in Allow).
|
||||||
@@ -184,6 +202,39 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a plugin contributed rules, a malformed user policy.yml must NOT
|
||||||
|
// abort: plugin rules shadow yaml entirely, so the broken file is never
|
||||||
|
// read. Regression -- previously LoadYAMLPolicy ran first and an
|
||||||
|
// unrelated broken yaml on the user's machine could fatal a
|
||||||
|
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
|
||||||
|
// plugin is present).
|
||||||
|
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||||
|
cfgDir := tmpHome(t)
|
||||||
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
|
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
|
||||||
|
|
||||||
|
pluginRules := []cmdpolicy.PluginRule{
|
||||||
|
{PluginName: "secaudit", Rule: &platform.Rule{
|
||||||
|
Name: "docs-only",
|
||||||
|
Allow: []string{"docs/**"},
|
||||||
|
MaxRisk: "write",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
root := fakeTree(t)
|
||||||
|
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||||
|
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
|
||||||
|
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
|
||||||
|
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
|
||||||
|
}
|
||||||
|
// docs/+update is within allow and at/below max_risk -> stays visible.
|
||||||
|
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
|
||||||
|
t.Errorf("docs/+update should remain visible under plugin rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||||
// Resolve and produces an error. This is the safety contract: a typo in
|
// Resolve and produces an error. This is the safety contract: a typo in
|
||||||
// the rule must not silently lower the pruning bar.
|
// the rule must not silently lower the pruning bar.
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,16 +34,8 @@ import (
|
|||||||
// lands directly on their RunE, which now carries the guard.
|
// lands directly on their RunE, which now carries the guard.
|
||||||
//
|
//
|
||||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
// typed error each time.
|
||||||
// as it serialises).
|
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
|
||||||
// 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) {
|
|
||||||
// Two cobra subcommands are injected lazily at Execute() time and
|
// Two cobra subcommands are injected lazily at Execute() time and
|
||||||
// would otherwise slip past walkGuard. We pre-register both so
|
// would otherwise slip past walkGuard. We pre-register both so
|
||||||
// walkGuard catches them.
|
// walkGuard catches them.
|
||||||
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||||
// failure as a structured plugin_install envelope before any command
|
// failure as a typed validation error (failed_precondition) before any
|
||||||
// runs.
|
// 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.
|
|
||||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() error {
|
||||||
var pi *internalplatform.PluginInstallError
|
var pi *internalplatform.PluginInstallError
|
||||||
if errors.As(installErr, &pi) {
|
if errors.As(installErr, &pi) {
|
||||||
return &output.ExitError{
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
|
||||||
Code: output.ExitValidation,
|
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
|
||||||
Detail: &output.ErrDetail{
|
WithCause(installErr)
|
||||||
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", 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)
|
installFatalGuard(rootCmd, makeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
// 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
|
// - "invalid_rule" - single bad rule
|
||||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
|
||||||
//
|
//
|
||||||
// Either way the CLI must NOT silently continue with a broken policy.
|
// 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) {
|
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() error {
|
||||||
envelopeType := "plugin_install"
|
|
||||||
reasonCode := internalplatform.ReasonInvalidRule
|
reasonCode := internalplatform.ReasonInvalidRule
|
||||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||||
envelopeType = "plugin_conflict"
|
|
||||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||||
}
|
}
|
||||||
return &output.ExitError{
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||||
Code: output.ExitValidation,
|
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
|
||||||
Detail: &output.ErrDetail{
|
WithCause(err)
|
||||||
Type: envelopeType,
|
|
||||||
Message: err.Error(),
|
|
||||||
Detail: map[string]any{
|
|
||||||
"reason_code": reasonCode,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
installFatalGuard(rootCmd, makeErr)
|
installFatalGuard(rootCmd, makeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
// failure as a typed validation error (failed_precondition). The hint's
|
||||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
// reason code splits returned-error vs panic so consumers (audit /
|
||||||
// two failure modes apart.
|
// 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.
|
|
||||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() error {
|
||||||
reasonCode := "lifecycle_failed"
|
reasonCode := "lifecycle_failed"
|
||||||
detail := map[string]any{
|
hookName := ""
|
||||||
"reason_code": reasonCode,
|
|
||||||
}
|
|
||||||
var le *hook.LifecycleError
|
var le *hook.LifecycleError
|
||||||
if errors.As(err, &le) {
|
if errors.As(err, &le) {
|
||||||
if le.Panic {
|
if le.Panic {
|
||||||
reasonCode = "lifecycle_panic"
|
reasonCode = "lifecycle_panic"
|
||||||
}
|
}
|
||||||
detail = map[string]any{
|
hookName = le.HookName
|
||||||
"reason_code": reasonCode,
|
|
||||||
"hook_name": le.HookName,
|
|
||||||
"event": "startup",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return &output.ExitError{
|
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||||
Code: output.ExitValidation,
|
WithCause(err)
|
||||||
Detail: &output.ErrDetail{
|
if hookName != "" {
|
||||||
Type: "plugin_lifecycle",
|
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
|
||||||
Message: err.Error(),
|
|
||||||
Detail: detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
}
|
||||||
|
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
|
||||||
}
|
}
|
||||||
installFatalGuard(rootCmd, makeErr)
|
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
|
// This way the very first non-nil step in cobra's chain is always our
|
||||||
// guard, regardless of which leaf the user invoked.
|
// guard, regardless of which leaf the user invoked.
|
||||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
func walkGuard(cmd *cobra.Command, makeErr func() error) {
|
||||||
// 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) {
|
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"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
|
// 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.
|
// The user must NEVER see a silent partial-install state.
|
||||||
//
|
//
|
||||||
// This pins the build.go fix for codex's NEW ISSUE about
|
// 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(t, leaf.RunE(leaf, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkGuardError asserts that err is the structured plugin_install
|
// checkGuardError asserts that err is the typed validation error the
|
||||||
// ExitError the guard produces.
|
// 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) {
|
func checkGuardError(t *testing.T, err error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "plugin_install" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
detail := exitErr.Detail.Detail.(map[string]any)
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
if detail["plugin"] != "policy" {
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
|
||||||
}
|
}
|
||||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
if !strings.Contains(verr.Hint, "policy") {
|
||||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
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"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = leaf.RunE(leaf, nil)
|
err = leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "hook" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
detail := exitErr.Detail.Detail.(map[string]any)
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
if detail["reason_code"] != "aborted" {
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
|
||||||
}
|
}
|
||||||
if detail["hook_name"] != "policy-plugin.policy" {
|
// The namespaced hook name and the abort semantics are preserved in the
|
||||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
// 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
|
// 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")
|
t.Fatalf("no runnable leaf in command tree")
|
||||||
}
|
}
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "plugin_conflict" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
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")
|
t.Fatalf("no runnable leaf in command tree")
|
||||||
}
|
}
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "plugin_install" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
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)
|
leaf := findRunnableLeaf(root)
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
d := exitErr.Detail.Detail.(map[string]any)
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
if d["reason_code"] != "lifecycle_failed" {
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
|
||||||
}
|
}
|
||||||
if d["hook_name"] != "lc.start" {
|
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
|
||||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
// 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)
|
leaf := findRunnableLeaf(root)
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
}
|
}
|
||||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
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)
|
err = leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "hook" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
d := exitErr.Detail.Detail.(map[string]any)
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
if d["reason_code"] != "panic" {
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
|
||||||
}
|
}
|
||||||
if d["hook_name"] != "p.boom" {
|
// The recovered panic surfaces as a structured error naming the
|
||||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
// 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)
|
err = leaf.RunE(leaf, nil)
|
||||||
var exitErr *output.ExitError
|
var verr *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &verr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "hook" {
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
}
|
}
|
||||||
d := exitErr.Detail.Detail.(map[string]any)
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
if d["reason_code"] != "panic" {
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
|
||||||
}
|
}
|
||||||
if d["hook_name"] != "fac.bad-factory" {
|
// A panic in the wrapper FACTORY (not just the inner handler) is
|
||||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
// 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,8 +12,10 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
||||||
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
||||||
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||||
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||||
|
|
||||||
_ = cmd.MarkFlagRequired("name")
|
_ = cmd.MarkFlagRequired("name")
|
||||||
@@ -52,51 +54,70 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||||
if err := core.ValidateProfileName(name); err != nil {
|
if err := core.ValidateProfileName(name); err != nil {
|
||||||
return output.ErrValidation("%v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
|
||||||
|
WithCause(err).
|
||||||
|
WithParam("--name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
langPref, err := cmdutil.ParseLangFlag(lang)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lang = string(langPref)
|
||||||
|
|
||||||
// Read secret from stdin
|
// Read secret from stdin
|
||||||
if !appSecretStdin {
|
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)
|
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
if err := scanner.Err(); err != nil {
|
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())
|
appSecret := strings.TrimSpace(scanner.Text())
|
||||||
if appSecret == "" {
|
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
|
// Load or create config
|
||||||
multi, err := core.LoadMultiAppConfig()
|
multi, err := core.LoadMultiAppConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
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 = &core.MultiAppConfig{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check name uniqueness
|
// Check name uniqueness
|
||||||
if multi.FindApp(name) != nil {
|
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
|
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||||
// multiple profiles sharing the same appId would collide on credentials.
|
// multiple profiles sharing the same appId would collide on credentials.
|
||||||
for _, a := range multi.Apps {
|
for _, a := range multi.Apps {
|
||||||
if a.AppId == appID {
|
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
|
// Store secret securely
|
||||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||||
if err != nil {
|
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 := core.ParseBrand(brand)
|
||||||
@@ -115,7 +136,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
|||||||
AppId: appID,
|
AppId: appID,
|
||||||
AppSecret: secret,
|
AppSecret: secret,
|
||||||
Brand: parsedBrand,
|
Brand: parsedBrand,
|
||||||
Lang: lang,
|
Lang: i18n.Lang(lang),
|
||||||
Users: []core.AppUser{},
|
Users: []core.AppUser{},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
|||||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||||
return nil
|
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 {
|
if multi == nil || len(multi.Apps) == 0 {
|
||||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -49,6 +51,66 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "failed to load config") {
|
if !strings.Contains(err.Error(), "failed to load config") {
|
||||||
t.Fatalf("error = %v, want failed to load config", err)
|
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:
|
||||||
|
// short codes and Feishu locales both canonicalize to the same stored locale,
|
||||||
|
// empty stores no preference, and an unrecognized value errors.
|
||||||
|
func TestProfileAddRun_Lang(t *testing.T) {
|
||||||
|
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
|
||||||
|
for _, in := range []string{"ja", "ja_jp"} {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
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()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
|
||||||
|
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty stores no preference", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
|
||||||
|
t.Fatalf("profileAddRun() error = %v", err)
|
||||||
|
}
|
||||||
|
saved, _ := core.LoadMultiAppConfig()
|
||||||
|
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
|
||||||
|
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid lang errors", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error for --lang ZH, got nil")
|
||||||
|
}
|
||||||
|
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) {
|
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||||
@@ -355,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
|||||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
var internalErr *errs.InternalError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &internalErr) {
|
||||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitInternal {
|
if internalErr.Subtype != errs.SubtypeStorage {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
if internalErr.Cause == nil {
|
||||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
t.Fatalf("cause = nil, want wrapped underlying error")
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
if !strings.Contains(internalErr.Message, wantMsg) {
|
||||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitInternal {
|
||||||
|
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertValidationError asserts err is a typed *errs.ValidationError with the
|
||||||
|
// given subtype, message fragment, and exit code 2.
|
||||||
|
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
var valErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &valErr) {
|
||||||
|
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
|
||||||
|
}
|
||||||
|
if valErr.Subtype != wantSubtype {
|
||||||
|
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(valErr.Message, wantMsg) {
|
||||||
|
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
|
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
return valErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTwoProfiles(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||||
|
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileAddRun_ValidationErrors(t *testing.T) {
|
||||||
|
t.Run("invalid profile name", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
|
||||||
|
if valErr.Param != "--name" {
|
||||||
|
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
|
||||||
|
}
|
||||||
|
if valErr.Cause == nil {
|
||||||
|
t.Fatal("cause = nil, want wrapped validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
|
||||||
|
if valErr.Param != "--app-secret-stdin" {
|
||||||
|
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
|
||||||
|
}
|
||||||
|
if valErr.Hint == "" {
|
||||||
|
t.Fatal("hint is empty, want actionable hint")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty stdin", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("")
|
||||||
|
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
|
||||||
|
if valErr.Param != "--app-secret-stdin" {
|
||||||
|
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank secret on stdin", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader(" \n")
|
||||||
|
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
|
||||||
|
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate profile name", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
|
||||||
|
if valErr.Param != "--name" {
|
||||||
|
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate app-id", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.IOStreams.In = strings.NewReader("secret\n")
|
||||||
|
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
|
||||||
|
if valErr.Param != "--app-id" {
|
||||||
|
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileUseRun_ValidationErrors(t *testing.T) {
|
||||||
|
t.Run("no previous profile for toggle", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileUseRun(f, "-")
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
|
||||||
|
if valErr.Hint == "" {
|
||||||
|
t.Fatal("hint is empty, want actionable hint")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile not found", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileUseRun(f, "ghost")
|
||||||
|
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
|
||||||
|
t.Run("invalid new name", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileRenameRun(f, "default", "bad name!")
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
|
||||||
|
if valErr.Cause == nil {
|
||||||
|
t.Fatal("cause = nil, want wrapped validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("old profile not found", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileRenameRun(f, "ghost", "fresh")
|
||||||
|
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new name already exists", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileRenameRun(f, "default", "target")
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
|
||||||
|
if valErr.Hint == "" {
|
||||||
|
t.Fatal("hint is empty, want actionable hint")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||||
|
t.Run("profile not found", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
saveTwoProfiles(t)
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileRemoveRun(f, "ghost")
|
||||||
|
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cannot remove the only profile", func(t *testing.T) {
|
||||||
|
setupProfileConfigDir(t)
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "solo",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileRemoveRun(f, "solo")
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
|
||||||
|
if valErr.Hint == "" {
|
||||||
|
t.Fatal("hint is empty, want actionable hint")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||||
|
dir := setupProfileConfigDir(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := profileListRun(f)
|
||||||
|
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
|
||||||
|
if valErr.Cause == nil {
|
||||||
|
t.Fatal("cause = nil, want wrapped load error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
|||||||
|
|
||||||
idx := multi.FindAppIndex(name)
|
idx := multi.FindAppIndex(name)
|
||||||
if idx < 0 {
|
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 {
|
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]
|
app := &multi.Apps[idx]
|
||||||
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort credential cleanup after config commit
|
// Best-effort credential cleanup after config commit
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||||
if err := core.ValidateProfileName(newName); err != nil {
|
if err := core.ValidateProfileName(newName); err != nil {
|
||||||
return output.ErrValidation("%v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
multi, err := core.LoadOrNotConfigured()
|
multi, err := core.LoadOrNotConfigured()
|
||||||
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
|||||||
|
|
||||||
idx := multi.FindAppIndex(oldName)
|
idx := multi.FindAppIndex(oldName)
|
||||||
if idx < 0 {
|
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
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||||
return output.ErrValidation("profile %q already exists", newName)
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
|
||||||
|
WithHint("choose a different name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
|||||||
// Handle "-" for toggle-back
|
// Handle "-" for toggle-back
|
||||||
if name == "-" {
|
if name == "-" {
|
||||||
if multi.PreviousApp == "" {
|
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
|
name = multi.PreviousApp
|
||||||
}
|
}
|
||||||
|
|
||||||
app := multi.FindApp(name)
|
app := multi.FindApp(name)
|
||||||
if app == nil {
|
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()
|
targetName := app.ProfileName()
|
||||||
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
|||||||
multi.CurrentApp = targetName
|
multi.CurrentApp = targetName
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user