mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0e724cbd4 | ||
|
|
03ba542a60 | ||
|
|
5fa68ccaa0 | ||
|
|
1583af7fc0 | ||
|
|
44e7b5b477 | ||
|
|
66ec27f6e1 | ||
|
|
162c25527b | ||
|
|
0c7a930fc3 | ||
|
|
ec9e67c21a | ||
|
|
74e4a97f52 | ||
|
|
fe4123436f | ||
|
|
052e2112bf | ||
|
|
76a834e928 | ||
|
|
20761fa56a | ||
|
|
2a301246f9 | ||
|
|
abc374f1a3 | ||
|
|
2910cde73a | ||
|
|
7fdc162ff7 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb | ||
|
|
1ff2dc578e | ||
|
|
69ae326d01 | ||
|
|
e07842d3b5 | ||
|
|
a9c07cebb6 | ||
|
|
f6a31e0853 | ||
|
|
bd5a33c0b7 | ||
|
|
3242ca6f7f | ||
|
|
368ec7e753 | ||
|
|
9f81e7e567 | ||
|
|
a00dfad56a | ||
|
|
8c799d5a9f | ||
|
|
474cb30a48 | ||
|
|
e8e0c6fc5a | ||
|
|
b8f71d50d1 | ||
|
|
46468a900c | ||
|
|
f59f263138 | ||
|
|
51d07be18a | ||
|
|
344ff88701 | ||
|
|
78ff1e7968 | ||
|
|
fa16fe1976 | ||
|
|
d8b0865814 | ||
|
|
d026741532 | ||
|
|
cd7a2363e5 | ||
|
|
353c473e52 | ||
|
|
76fac115ed | ||
|
|
d2a834051d | ||
|
|
d30a9472c3 | ||
|
|
b8fa2b3f80 | ||
|
|
6ec19cbc84 | ||
|
|
d7363b0481 | ||
|
|
5f3915b25c | ||
|
|
4e65ea808e | ||
|
|
d7262b7dc5 | ||
|
|
c16a021ac6 | ||
|
|
fd9ee6afd6 | ||
|
|
69cf9f206e | ||
|
|
99b8aaa556 | ||
|
|
b4a26b2cdc | ||
|
|
619ec8c2cb | ||
|
|
eb3c643f0b | ||
|
|
37747177fc | ||
|
|
9d48ef422b | ||
|
|
e64d24580a | ||
|
|
3db4f42ab8 | ||
|
|
0bf4f80ef4 | ||
|
|
284e5b6606 | ||
|
|
af83e5495b | ||
|
|
a3bced3ee5 | ||
|
|
35108e1798 | ||
|
|
30b97e1bdd | ||
|
|
2715b560b7 | ||
|
|
15bd134f5c | ||
|
|
daa21731ad | ||
|
|
9fab62bf00 | ||
|
|
cdd9f9ab49 | ||
|
|
d5d31f0ee4 | ||
|
|
67cb0a961e | ||
|
|
aa4076a7cc | ||
|
|
db9ca5c2a4 | ||
|
|
1f8d4b211d | ||
|
|
63ea52b2e6 | ||
|
|
555722ac8e | ||
|
|
f5a8fbf8f1 | ||
|
|
adef52ada5 | ||
|
|
6ac5b4d566 | ||
|
|
7158dc2f3c | ||
|
|
c54a1354a0 | ||
|
|
a73c9ae27e | ||
|
|
900c12ce8d | ||
|
|
f3c3a4c49f | ||
|
|
2e345a4fdd | ||
|
|
78bc66ce14 | ||
|
|
fe8da8d924 | ||
|
|
12bb01addf | ||
|
|
d6fada01f5 | ||
|
|
6bc6bb67aa | ||
|
|
a1438586ec | ||
|
|
c9b660ae12 | ||
|
|
567b40778b | ||
|
|
ec23995bce | ||
|
|
1980b999f7 | ||
|
|
1be9a241b7 | ||
|
|
f4afa47de8 | ||
|
|
bb38ecd41a | ||
|
|
9f0758bfef | ||
|
|
d3d92e37c2 | ||
|
|
b064188f20 | ||
|
|
799179fde6 | ||
|
|
8db4528269 | ||
|
|
30dba35c77 | ||
|
|
2efadece34 | ||
|
|
b7613d64bd | ||
|
|
0c77c95a11 | ||
|
|
135fde8b6d | ||
|
|
5cf866739d | ||
|
|
77460abc49 | ||
|
|
a641fdd5e6 | ||
|
|
8645d26d09 | ||
|
|
b5b23fe82a | ||
|
|
84258980c6 | ||
|
|
51a6adab2b | ||
|
|
9e367b4736 | ||
|
|
56ed529c1b | ||
|
|
f67f569e76 | ||
|
|
f930d9c52f | ||
|
|
7c3d5b31d5 | ||
|
|
bf537f8d9c | ||
|
|
10caeb5788 | ||
|
|
6a4dd8dc1b | ||
|
|
1f3d9e0420 | ||
|
|
6692300468 | ||
|
|
7baba213bc | ||
|
|
725a62879b | ||
|
|
112dd5f6b2 | ||
|
|
0f96bdf5e8 | ||
|
|
102ee51914 | ||
|
|
79f43dc337 | ||
|
|
f231031041 | ||
|
|
f68a41163e | ||
|
|
eda2b9cd85 | ||
|
|
a703202ef8 | ||
|
|
eb8b542f42 | ||
|
|
d4c051d211 | ||
|
|
5621d2e555 | ||
|
|
17698d5c6a | ||
|
|
70c72a2c02 | ||
|
|
d4e83df22c | ||
|
|
4c51a9874d | ||
|
|
6463ab13c9 | ||
|
|
c4851a5c45 | ||
|
|
bdd39b0196 | ||
|
|
1ffe870dc8 | ||
|
|
5da3075646 | ||
|
|
8fc7e12f9e | ||
|
|
27139a0919 | ||
|
|
c35b1ae2c5 | ||
|
|
c8341bbd7c | ||
|
|
634adfc745 | ||
|
|
62d8681b0b | ||
|
|
a2656e1385 | ||
|
|
8bd5049ebe | ||
|
|
69bcdd9e35 | ||
|
|
9b933f1a20 | ||
|
|
8e24166d90 | ||
|
|
ecf3209c52 | ||
|
|
a13bee8fda | ||
|
|
e5a83f5eaa | ||
|
|
d2ad5e4def | ||
|
|
511c24bd95 | ||
|
|
62ad335b26 | ||
|
|
d4d4f32ec6 | ||
|
|
aac94ceb5c | ||
|
|
2345b98d20 | ||
|
|
ccbf4a0bd6 |
8
.codecov.yml
Normal file
8
.codecov.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
<!-- Briefly describe the motivation and scope of this change in 1-3 sentences. -->
|
||||
|
||||
## Changes
|
||||
<!-- List the main changes in this PR. -->
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
- None
|
||||
83
.github/workflows/cli-e2e.yml
vendored
Normal file
83
.github/workflows/cli-e2e.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- scripts/fetch_meta.py
|
||||
- tests/cli_e2e/**
|
||||
- .github/workflows/cli-e2e.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Build lark-cli
|
||||
run: make build
|
||||
|
||||
- name: Configure bot credentials
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
44
.github/workflows/coverage.yml
vendored
44
.github/workflows/coverage.yml
vendored
@@ -2,22 +2,34 @@ name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- "!tests/cli_e2e/**"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codecov:
|
||||
runs-on: ubuntu-22.04
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -27,10 +39,20 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
run: |
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
28
.github/workflows/gitleaks.yml
vendored
Normal file
28
.github/workflows/gitleaks.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
57
.github/workflows/issue-labels.yml
vendored
Normal file
57
.github/workflows/issue-labels.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Issue Labels
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Do not write labels, only print planned changes"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-issue-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# v6+ uses Node 24 runtime for JavaScript actions.
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Sync managed issue labels
|
||||
id: sync_issue_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
args=(
|
||||
"--max-issues" "300"
|
||||
)
|
||||
|
||||
# Schedule runs should write labels by default.
|
||||
# Manual runs default to dry-run unless explicitly disabled.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
|
||||
args+=("--dry-run" "--json")
|
||||
fi
|
||||
|
||||
node scripts/issue-labels/index.js "${args[@]}"
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::Issue label sync failed; labels may be stale."
|
||||
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
26
.github/workflows/license-header.yml
vendored
Normal file
26
.github/workflows/license-header.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
78
.github/workflows/lint.yml
vendored
78
.github/workflows/lint.yml
vendored
@@ -2,43 +2,36 @@ name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
staticcheck:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run staticcheck
|
||||
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1
|
||||
with:
|
||||
install-go: false
|
||||
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -47,26 +40,21 @@ jobs:
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6
|
||||
with:
|
||||
version: latest
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
vet:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
name: PR Preview Package Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Preview Package"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check comment payload artifact
|
||||
id: payload
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run?.id;
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
per_page: 100,
|
||||
});
|
||||
const found = Boolean(
|
||||
data.artifacts?.some((artifact) => artifact.name === "pkg-pr-new-comment-payload")
|
||||
);
|
||||
core.setOutput("found", found ? "true" : "false");
|
||||
if (!found) {
|
||||
core.notice("No comment payload artifact found for this run; skipping comment.");
|
||||
}
|
||||
|
||||
- name: Download comment payload
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Comment install command
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const payload = JSON.parse(fs.readFileSync("pkg-pr-new-comment-payload.json", "utf8"));
|
||||
const url = payload?.url;
|
||||
const payloadPr = payload?.pr;
|
||||
const sourceRepo = payload?.sourceRepo;
|
||||
const sourceBranch = payload?.sourceBranch;
|
||||
if (!Number.isInteger(payloadPr)) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
if (payloadPr <= 0) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
const issueNumber = payloadPr;
|
||||
const runPrNumber = context.payload.workflow_run?.pull_requests?.[0]?.number;
|
||||
if (Number.isInteger(runPrNumber) && runPrNumber !== issueNumber) {
|
||||
throw new Error(
|
||||
`PR number mismatch between workflow_run (${runPrNumber}) and artifact payload (${issueNumber})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof url !== "string" || url.trim() !== url || /[\u0000-\u001F\u007F]/.test(url)) {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "pkg.pr.new") {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
|
||||
const safeRepoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const safeBranchPattern = /^[A-Za-z0-9._\/-]+$/;
|
||||
const hasSkillSource =
|
||||
typeof sourceRepo === "string" &&
|
||||
typeof sourceBranch === "string" &&
|
||||
safeRepoPattern.test(sourceRepo) &&
|
||||
safeBranchPattern.test(sourceBranch);
|
||||
const skillSection = hasSkillSource
|
||||
? [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"```bash",
|
||||
`npx skills add ${sourceRepo}#${sourceBranch} -y -g`,
|
||||
"```",
|
||||
]
|
||||
: [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"_Unavailable for this PR because source repo/branch metadata is missing._",
|
||||
];
|
||||
|
||||
const body = [
|
||||
"<!-- pkg-pr-new-install-guide -->",
|
||||
"## 🚀 PR Preview Install Guide",
|
||||
"",
|
||||
"### 🧰 CLI update",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
...skillSection,
|
||||
].join("\n");
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.includes("<!-- pkg-pr-new-install-guide -->")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
71
.github/workflows/pkg-pr-new.yml
vendored
Normal file
71
.github/workflows/pkg-pr-new.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: PR Preview Package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Build preview package
|
||||
run: ./scripts/build-pkg-pr-new.sh
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
|
||||
|
||||
- name: Build comment payload
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) throw new Error("No package URL found in output.json");
|
||||
if (!url.startsWith("https://pkg.pr.new/")) {
|
||||
throw new Error(`Unexpected package URL: ${url}`);
|
||||
}
|
||||
|
||||
const pr = Number(process.env.PR_NUMBER);
|
||||
if (!Number.isInteger(pr) || pr <= 0) {
|
||||
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pr,
|
||||
url,
|
||||
sourceRepo: process.env.SOURCE_REPO || "",
|
||||
sourceBranch: process.env.SOURCE_BRANCH || "",
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
"pkg-pr-new-comment-payload.json",
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload comment payload
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
path: pkg-pr-new-comment-payload.json
|
||||
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Test PR Label Logic
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-pr-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run PR label regression tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/test.js
|
||||
43
.github/workflows/pr-labels.yml
vendored
Normal file
43
.github/workflows/pr-labels.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: PR Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NOTE: This event runs with base-branch code and write permissions.
|
||||
# Do NOT add `ref: github.event.pull_request.head.sha` to the checkout step,
|
||||
# as that would execute untrusted PR code with elevated access.
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-pr-labels:
|
||||
if: ${{ github.event.pull_request.state == 'open' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Sync managed PR labels
|
||||
id: sync_pr_labels
|
||||
# Labeling is best-effort and must not block PR merges.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/index.js
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_pr_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::PR label sync failed; labels may be stale."
|
||||
echo "⚠️ PR label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -33,3 +33,19 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
32
.github/workflows/skill-format-check.yml
vendored
Normal file
32
.github/workflows/skill-format-check.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Skill Format Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run Skill Format Check
|
||||
run: node scripts/skill-format-check/index.js
|
||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
@@ -2,22 +2,32 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -27,4 +37,7 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,3 +30,9 @@ test_scripts/
|
||||
tests/mail/reports/
|
||||
|
||||
/log/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
16
.gitleaks.toml
Normal file
16
.gitleaks.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
title = "lark-cli gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "lark-bot-app-id"
|
||||
description = "Detect Lark bot app ids"
|
||||
regex = '''\bcli_[a-z0-9]{16}\b'''
|
||||
keywords = ["cli_"]
|
||||
|
||||
[[rules]]
|
||||
id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
127
.golangci.yml
Normal file
127
.golangci.yml
Normal file
@@ -0,0 +1,127 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- asasalint # checks for pass []any as any in variadic func(...any)
|
||||
- asciicheck # checks that code does not contain non-ASCII identifiers
|
||||
- bidichk # checks for dangerous unicode character sequences
|
||||
- bodyclose # checks whether HTTP response body is closed successfully
|
||||
- copyloopvar # detects places where loop variables are copied
|
||||
- durationcheck # checks for two durations multiplied together
|
||||
- exptostd # detects functions from golang.org/x/exp/ replaceable by std
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- gochecksumtype # checks exhaustiveness on Go "sum types"
|
||||
- gocritic # diagnostics for bugs, performance and style
|
||||
- gomoddirectives # checks for replace, retract, and exclude in go.mod
|
||||
- goprintffuncname # checks that printf-like functions end with f
|
||||
- govet # reports suspicious constructs
|
||||
- ineffassign # detects ineffective assignments
|
||||
- nilerr # finds code that returns nil even if error is not nil
|
||||
- nolintlint # reports ill-formed nolint directives
|
||||
- nosprintfhostport # checks for misuse of Sprintf to construct host:port
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- depguard # blocks forbidden package imports
|
||||
- forbidigo # forbids specific function calls
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
# - errname # checks that error types are named XxxError
|
||||
# - errorlint # checks error wrapping best practices
|
||||
# - gosec # security-oriented linter
|
||||
# - misspell # finds commonly misspelled English words
|
||||
# - staticcheck # comprehensive static analysis
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- generated
|
||||
rules:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
shortcuts-no-vfs:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "github.com/larksuite/cli/internal/vfs"
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
|
||||
msg: "use the corresponding vfs.Xxx() from internal/vfs"
|
||||
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
|
||||
msg: >-
|
||||
internal/: use vfs.CreateTemp() or vfs.OpenFile().
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
- pattern: os\.Mkdir(All)?\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.Remove\b
|
||||
msg: >-
|
||||
internal/: use vfs.Remove() from internal/vfs.
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
- pattern: os\.RemoveAll\b
|
||||
msg: >-
|
||||
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
|
||||
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
|
||||
# ── os: not yet in vfs — add to vfs/fs.go first ──
|
||||
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
|
||||
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
|
||||
# ── os: IO streams ──
|
||||
- pattern: os\.Std(in|out|err)\b
|
||||
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
|
||||
# ── os: process ──
|
||||
- pattern: os\.Exit\b
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
# ── filepath: functions that access the filesystem ──
|
||||
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
|
||||
msg: >-
|
||||
These filepath functions access the filesystem directly.
|
||||
internal/: use vfs helpers or localfileio path validation.
|
||||
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- hugeParam
|
||||
disabled-tags:
|
||||
- style
|
||||
govet:
|
||||
enable:
|
||||
- httpresponse
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
16
.licenserc.yaml
Normal file
16
.licenserc.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
header:
|
||||
license:
|
||||
content: |
|
||||
Copyright (c) [year] Lark Technologies Pte. Ltd.
|
||||
SPDX-License-Identifier: MIT
|
||||
copyright-year: "2026"
|
||||
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- '**/*.js'
|
||||
- '**/*.py'
|
||||
|
||||
paths-ignore:
|
||||
- '**/testdata/**'
|
||||
|
||||
comment: on-failure
|
||||
78
AGENTS.md
Normal file
78
AGENTS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Goal (pick one per PR)
|
||||
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
|
||||
## Commit & PR
|
||||
|
||||
- Conventional Commits in English: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:`
|
||||
- PR title in the same format. Fill `.github/pull_request_template.md` completely.
|
||||
- Never commit secrets, tokens, or internal sensitive data.
|
||||
|
||||
## Source Layout
|
||||
|
||||
| Path | What it does |
|
||||
|------|-------------|
|
||||
| `cmd/root.go` | Entry point, command registration, strict mode pruning |
|
||||
| `cmd/profile/` | Multi-profile management (add/list/use/rename/remove) |
|
||||
| `cmd/config/` | Config init, show, strict-mode |
|
||||
| `cmd/service/` | Auto-registered API commands from embedded metadata |
|
||||
| `shortcuts/common/runner.go` | Shortcut execution pipeline, Flag.Input (@file/stdin) resolution |
|
||||
| `shortcuts/` | Domain-specific shortcut implementations |
|
||||
| `internal/cmdutil/factory.go` | Factory pattern — identity resolution, credential, config |
|
||||
| `internal/cmdutil/factory_default.go` | Production factory wiring |
|
||||
| `internal/credential/` | Credential provider chain (extension → default) |
|
||||
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
|
||||
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
|
||||
| `internal/core/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
## Who Uses This CLI
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
|
||||
|
||||
The one rule to internalize: **every error message you write will be parsed by an AI to decide its next action.** Make errors structured, actionable, and specific.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### 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.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
|
||||
### Validate paths before reading
|
||||
|
||||
CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInputPath` before any file I/O.
|
||||
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
Binary file not shown.
319
CHANGELOG.md
319
CHANGELOG.md
@@ -2,6 +2,311 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.12] - 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
|
||||
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
|
||||
- **mail**: Return recall hints for sent emails when recall is available (#481)
|
||||
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
|
||||
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support im oapi range download for large files (#283)
|
||||
- **sheets**: Add filter view and condition shortcuts (#422)
|
||||
- **wiki**: Add wiki move shortcut with async task polling (#436)
|
||||
- **drive**: Add drive `+create-shortcut` shortcut (#432)
|
||||
- **drive**: Add drive files patch metadata API (#444)
|
||||
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Support large base attachment uploads (#441)
|
||||
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
|
||||
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
|
||||
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
|
||||
- **mail**: Restrict `--output-dir` to current working directory (#376)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
|
||||
- **task**: Document sections API resources, permissions, and URL parsing (#430)
|
||||
- **doc**: Clarify when markdown escaping is needed (#312)
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
|
||||
- Add attendance `user_task.query` (#405)
|
||||
- Support minutes search (#359)
|
||||
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
|
||||
- **slides**: Return presentation URL in slides `+create` output (#425)
|
||||
- **sheets**: Add dimension shortcuts for row/column operations (#413)
|
||||
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
|
||||
- **drive**: Add drive folder delete shortcut with async task polling (#415)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Add guide for granting document permission to current bot (#414)
|
||||
|
||||
## [v1.0.8] - 2026-04-10
|
||||
|
||||
### Features
|
||||
|
||||
- Add `update` command with self-update, verification, and rollback (#391)
|
||||
- Add `--file` flag for multipart/form-data file uploads (#395)
|
||||
- Support file comment reply reactions (#380)
|
||||
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
|
||||
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
|
||||
- **base**: Add `+record-search` for keyword-based record search (#328)
|
||||
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
|
||||
- **base**: Add record field filters (#327)
|
||||
- **base**: Optimize workflow skills (#345)
|
||||
- **calendar**: Add room find workflow (#403)
|
||||
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
|
||||
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve error hints for sandbox and initialization issues (#384)
|
||||
- Fix markdown line breaks support (#338)
|
||||
- Return raw base field and view responses (#378)
|
||||
- **base**: Return raw table list response and clarify sort help (#393)
|
||||
- **calendar**: Add default video meeting to `+create` (#383)
|
||||
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Document Base attachment download via docs `+media-download` (#404)
|
||||
- Reorganize lark-base skill guidance (#374)
|
||||
|
||||
## [v1.0.7] - 2026-04-09
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
|
||||
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
|
||||
- **vc**: Extract note doc tokens from calendar event relation API (#333)
|
||||
- **wiki**: Add wiki node create shortcut (#320)
|
||||
- **sheets**: Add `+write-image` shortcut (#343)
|
||||
- **docs**: Add media-preview shortcut (#334)
|
||||
- **docs**: Add support for additional search filters (#353)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
|
||||
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
|
||||
- **run**: Add missing binary check for lark-cli execution (#362)
|
||||
- **config**: Validate appId and appSecret keychain key consistency (#295)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Route base import guidance to drive `+import` (#368)
|
||||
- Migrate mail shortcuts to FileIO (#356)
|
||||
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
|
||||
- Migrate base shortcuts to FileIO (#347)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
|
||||
|
||||
## [v1.0.6] - 2026-04-08
|
||||
|
||||
### Features
|
||||
|
||||
- Improve login scope validation and success output (#317)
|
||||
- **task**: Support starting pagination from page token (#332)
|
||||
- Support multipart doc media uploads (#294)
|
||||
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
|
||||
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve concurrency races in RuntimeContext (#330)
|
||||
- **config**: Save empty config before clearing keychain entries (#291)
|
||||
- Reject positional arguments in shortcuts (#227)
|
||||
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
|
||||
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
|
||||
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
|
||||
- **help**: Point root help Agent Skills link to README section (#289)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate VC/minutes shortcuts to FileIO (#336)
|
||||
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Support multipart upload for files larger than 20MB (#43)
|
||||
- Add darwin file master key fallback for keychain writes (#285)
|
||||
- Add strict mode identity filter, profile management and credential extension (#252)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Restore CID validation and stale PartID lookup lost in revert (#230)
|
||||
- **base**: Clarify table-id `tbl` prefix requirement (#270)
|
||||
- Fix parameter constraints for LarkMessageTrigger (#213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix root calendar example (#299)
|
||||
- Fix README auth scope and api data flag (#298)
|
||||
- Clarify task guid for applinks (#287)
|
||||
- Clarify lark task guid usage (#282)
|
||||
- **lark-base**: Add `has_more` guidance for record-list pagination (#183)
|
||||
|
||||
### Tests
|
||||
|
||||
- Isolate registry package state in tests (#280)
|
||||
|
||||
### CI
|
||||
|
||||
- Add scheduled issue labeler for type/domain triage (#251)
|
||||
- **issue-labels**: Reduce mislabeling and handle missing labels (#288)
|
||||
- Map wiki paths in pr labels (#249)
|
||||
|
||||
## [v1.0.4] - 2026-04-03
|
||||
|
||||
### Features
|
||||
|
||||
- Support user identity for im `+chat-create` (#242)
|
||||
- Implement authentication response logging (#235)
|
||||
- Support im chat member delete and add scope notes (#229)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **security**: Replace `http.DefaultTransport` with proxy-aware base transport to mitigate MITM risk (#247)
|
||||
- **calendar**: Block auto bot fallback without user login (#245)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add identity guidance to prefer user over bot (#157)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **dashboard**: Restructure docs for AI-friendly navigation (#191)
|
||||
|
||||
### CI
|
||||
|
||||
- Add a CLI E2E testing framework for lark-cli, task domain testcase and ci action (#236)
|
||||
|
||||
## [v1.0.3] - 2026-04-02
|
||||
|
||||
### Features
|
||||
|
||||
- Add `--jq` flag for filtering JSON output (#211)
|
||||
- Add `+download` shortcut for minutes media download (#101)
|
||||
- Add drive import, export, move, and task result shortcuts (#194)
|
||||
- Support im message send/reply with uat (#180)
|
||||
- Add approve domain (#217)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
|
||||
- **mail**: On-demand scope checks and watch event filtering (#198)
|
||||
- Use curl for binary download to support proxy and add npmmirror fallback (#226)
|
||||
- Normalize escaped sheet range separators (#207)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Clarify JSON output is directly usable without extra encoding (#228)
|
||||
- Clarify docs search query usage (#221)
|
||||
|
||||
### CI
|
||||
|
||||
- Add gitleaks scanning workflow and custom rules (#142)
|
||||
|
||||
## [v1.0.2] - 2026-04-01
|
||||
|
||||
### Features
|
||||
|
||||
- Improve OS keychain/DPAPI access error handling for sandbox environments (#173)
|
||||
- **mail**: Auto-resolve local image paths in draft body HTML (#139)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct URL formatting in login `--no-wait` output (#169)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add concise AGENTS development guide (#178)
|
||||
|
||||
### CI
|
||||
|
||||
- Refine PR business area labels and introduce skill format check (#148)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add pull request template (#176)
|
||||
|
||||
## [v1.0.1] - 2026-03-31
|
||||
|
||||
### Features
|
||||
|
||||
- Add automatic CLI update detection and notification (#144)
|
||||
- Add npm publish job to release workflow (#145)
|
||||
- Support auto extension for downloads (#16)
|
||||
- Remove useless files (#131)
|
||||
- Normalize markdown message send/reply output (#28)
|
||||
- Add auto-pagination to messages search and update lark-im docs (#30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Use base history read scope for record history list (#96)
|
||||
- Remove sensitive send scope from reply and forward shortcuts (#92)
|
||||
- Resolve silent failure in `lark-cli api` error output (#85)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify field description usage in json (#90)
|
||||
- Update Base description to include all capabilities (#61)
|
||||
- Add official badge to distinguish from third-party Lark CLI tools (#103)
|
||||
- Rename user-facing Bitable references to Base (#11)
|
||||
- Add star history chart to readmes (#12)
|
||||
- Simplify installation steps by merging CLI and Skills into one section (#26)
|
||||
- Add npm version badge and improve AI agent tip wording (#23)
|
||||
- Emphasize Skills installation as required for AI Agents (#19)
|
||||
- Clarify install methods as alternatives and add source build steps
|
||||
|
||||
### CI
|
||||
|
||||
- Improve CI workflows and add golangci-lint config (#71)
|
||||
|
||||
## [v1.0.0] - 2026-03-28
|
||||
|
||||
### Initial Release
|
||||
@@ -27,7 +332,7 @@ Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `
|
||||
- **Drive** — Upload, download, and manage cloud documents.
|
||||
- **Docs** — Work with Lark documents.
|
||||
- **Sheets** — Interact with spreadsheets.
|
||||
- **Base (Bitable)** — Manage multi-dimensional tables.
|
||||
- **Base** — Manage multi-dimensional tables.
|
||||
- **Calendar** — Create and manage calendar events.
|
||||
- **Mail** — Send and manage emails.
|
||||
- **Contact** — Look up users and departments.
|
||||
@@ -54,4 +359,16 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
28
CLA.md
28
CLA.md
@@ -1,28 +0,0 @@
|
||||
> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose.
|
||||
> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement.
|
||||
|
||||
**ByteDance Individual Contributor License Agreement v1.** **1**
|
||||
By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions.
|
||||
1.Definitions.
|
||||
"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists).
|
||||
"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates.
|
||||
2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance.
|
||||
5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission.
|
||||
10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance.
|
||||
|
||||
[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf)
|
||||
|
||||
This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project.
|
||||
ByteDance Corporate Contributor License Agreement v1.1.pdf
|
||||
A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer.
|
||||
If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records.
|
||||
|
||||
If you need to update your CLA, please email <opensource-cla@bytedance.com> from the email address associated with your individual or corporate information.
|
||||
145
README.md
145
README.md
@@ -2,17 +2,18 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://www.npmjs.com/package/@larksuite/cli)
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -21,19 +22,22 @@ A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -46,31 +50,32 @@ Before you start, make sure you have:
|
||||
|
||||
### Quick Start (Human Users)
|
||||
|
||||
> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent).
|
||||
> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete.
|
||||
|
||||
#### Install CLI
|
||||
#### Install
|
||||
|
||||
**From npm (recommended):**
|
||||
Choose **one** of the following methods:
|
||||
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**From source:**
|
||||
**Option 2 — From source:**
|
||||
|
||||
Requires Go `v1.23`+ and Python 3.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/larksuite/cli.git
|
||||
cd cli
|
||||
make install
|
||||
```
|
||||
|
||||
#### Install AI Agent Skills
|
||||
|
||||
[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI:
|
||||
|
||||
```bash
|
||||
# Install all skills to current directory
|
||||
npx skills add larksuite/cli -y
|
||||
|
||||
# Install all skills globally
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
@@ -91,48 +96,64 @@ lark-cli calendar +agenda
|
||||
|
||||
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
|
||||
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# 1. Install CLI
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 2. Install Skills (enables AI Agent to use this CLI)
|
||||
npx skills add larksuite/cli --all -y
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
# 3. Configure app credentials
|
||||
# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser.
|
||||
**Step 2 — Configure app credentials**
|
||||
|
||||
> Run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in the browser.
|
||||
|
||||
```bash
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
# 4. Login
|
||||
# Same as above: run in the background, extract the authorization URL and send it to the user.
|
||||
**Step 3 — Login**
|
||||
|
||||
> Same as above: run in the background, extract the authorization URL and send it to the user.
|
||||
|
||||
```bash
|
||||
lark-cli auth login --recommend
|
||||
```
|
||||
|
||||
# 5. Verify
|
||||
**Step 4 — Verify**
|
||||
|
||||
```bash
|
||||
lark-cli auth status
|
||||
```
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -156,7 +177,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# Exact scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent mode: return verification URL immediately, non-blocking
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -199,7 +220,7 @@ Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
@@ -250,6 +271,10 @@ We recommend using the Lark/Feishu bot integrated with this tool as a private co
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
145
README.zh.md
145
README.zh.md
@@ -2,17 +2,18 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://www.npmjs.com/package/@larksuite/cli)
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -21,19 +22,22 @@
|
||||
|
||||
## 功能
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -46,31 +50,32 @@
|
||||
|
||||
### 快速开始(人类用户)
|
||||
|
||||
> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始(AI Agent)](#快速开始ai-agent)查看。
|
||||
> **Note for AI assistants:** 如果你是 AI Agent 且正在协助用户执行安装,请直接跳转到[快速开始(AI Agent)](#快速开始ai-agent)执行,那里包含你需要完成的所有步骤。
|
||||
|
||||
#### 安装 CLI
|
||||
#### 安装
|
||||
|
||||
**从 npm 安装(推荐):**
|
||||
以下两种方式**任选其一**:
|
||||
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**从源码安装:**
|
||||
**方式二 — 从源码安装:**
|
||||
|
||||
需要 Go `v1.23`+ 和 Python 3。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/larksuite/cli.git
|
||||
cd cli
|
||||
make install
|
||||
```
|
||||
|
||||
#### 安装 AI Agent Skills
|
||||
|
||||
[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI:
|
||||
|
||||
```bash
|
||||
# 安装所有 skills 到当前目录
|
||||
npx skills add larksuite/cli -y
|
||||
|
||||
# 安装所有 skills 到全局
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
@@ -91,49 +96,65 @@ lark-cli calendar +agenda
|
||||
|
||||
> 以下步骤面向 AI Agent,部分步骤需要用户在浏览器中配合完成。
|
||||
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 1. 安装 CLI
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 2. 安装 Skills(使 AI Agent 能够使用本 CLI)
|
||||
npx skills add larksuite/cli --all -y
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
# 3. 配置应用凭证
|
||||
# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
|
||||
**第 2 步 — 配置应用凭证**
|
||||
|
||||
> 在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
|
||||
|
||||
```bash
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
# 4. 登录
|
||||
# 同上,后台运行,提取授权链接发给用户
|
||||
**第 3 步 — 登录**
|
||||
|
||||
> 同上,后台运行,提取授权链接发给用户。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --recommend
|
||||
```
|
||||
|
||||
# 5. 验证
|
||||
**第 4 步 — 验证**
|
||||
|
||||
```bash
|
||||
lark-cli auth status
|
||||
```
|
||||
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
## 认证
|
||||
|
||||
@@ -157,7 +178,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# 精确 scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent 模式:立即返回验证 URL,不阻塞
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -200,7 +221,7 @@ lark-cli calendar events instance_view --params '{"calendar_id":"primary","start
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## 进阶用法
|
||||
@@ -251,6 +272,10 @@ lark-cli schema im.messages.delete
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
142
cmd/api/api.go
142
cmd/api/api.go
@@ -5,7 +5,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -40,18 +39,9 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
|
||||
if input == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
|
||||
}
|
||||
return result, nil
|
||||
File string
|
||||
}
|
||||
|
||||
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
|
||||
@@ -87,8 +77,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
@@ -96,7 +86,9 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
@@ -115,20 +107,24 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
|
||||
// buildAPIRequest validates flags and builds a RawApiRequest.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
params, err := parseJsonOpt(opts.Params, "--params")
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
var data interface{}
|
||||
if opts.Data != "" {
|
||||
data, err = parseJsonOpt(opts.Data, "--data")
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
params["page_size"] = opts.PageSize
|
||||
@@ -138,35 +134,84 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
Method: opts.Method,
|
||||
URL: normalisePath(opts.Path),
|
||||
Params: params,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload path: build formdata.
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
|
||||
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
request, fileMeta, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
@@ -184,36 +229,43 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
})
|
||||
// MarkRaw tells root error handler that the API response was already written
|
||||
// to stdout, so it should skip the stderr error envelope. Only apply when
|
||||
// HandleResponse actually wrote output (i.e. returned a business/API error
|
||||
// after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain)
|
||||
// produce no stdout output and need the envelope.
|
||||
if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) {
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, 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, pagOpts client.PaginationOptions) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -5,6 +5,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -70,16 +71,6 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-test-token",
|
||||
"expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register API endpoint stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
@@ -209,6 +200,22 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both --params and --data use stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -234,13 +241,6 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-bin", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/drive/v1/files/xxx/download",
|
||||
RawBody: []byte("fake-binary-content"),
|
||||
@@ -266,14 +266,6 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a non-batch API that returns scalar data (no array field)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
@@ -310,13 +302,6 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-err", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Non-batch API that returns a business error (code != 0)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
@@ -346,14 +331,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub (unique app credentials => new token request)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a batch API that returns an array field
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
@@ -409,13 +386,6 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-raw", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
@@ -446,10 +416,9 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
t.Error("expected API error from api command to be marked Raw")
|
||||
}
|
||||
|
||||
// stderr should NOT contain an error envelope (identity line is OK)
|
||||
if strings.Contains(stderr.String(), `"ok"`) {
|
||||
t.Error("expected no JSON error envelope on stderr for Raw API error")
|
||||
}
|
||||
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
@@ -457,13 +426,6 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
@@ -501,18 +463,48 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
@@ -537,6 +529,165 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(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", "--jq", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_ShortForm(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", "-q", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
// Should NOT contain the full envelope structure
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", 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": "u1"}, map[string]interface{}{"id": "u2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -556,3 +707,98 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.File != "image=photo.jpg" {
|
||||
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with --output")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutual exclusion error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with GET")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires POST") {
|
||||
t.Errorf("expected method error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file stdin with --data stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -48,7 +50,7 @@ type userInfoResponse struct {
|
||||
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/authen/v1/user_info",
|
||||
ApiPath: larkauth.PathUserInfoV1,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
||||
}, larkcore.WithUserAccessToken(accessToken))
|
||||
if err != nil {
|
||||
@@ -99,7 +101,7 @@ type appInfoResponse struct {
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
ac, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("lang", "zh_cn")
|
||||
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/application/v6/applications/" + appId,
|
||||
QueryParams: queryParams,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"creator_id": "ou_creator",
|
||||
"scopes": []map[string]interface{}{
|
||||
{
|
||||
"scope": "im:message",
|
||||
"token_types": []string{"tenant"},
|
||||
},
|
||||
{
|
||||
"scope": "im:message:send_as_user",
|
||||
"token_types": []string{"user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(appInfoStub)
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authScopesRun() error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenResolver.requests) != 1 {
|
||||
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
|
||||
}
|
||||
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
|
||||
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
|
||||
}
|
||||
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.TokenResult{Token: "tenant-token"}, nil
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.TokenResult{Token: "user-token"}, nil
|
||||
default:
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func authListRun(opts *ListOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type LoginOptions struct {
|
||||
DeviceCode string
|
||||
}
|
||||
|
||||
var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
@@ -46,6 +48,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,6 +61,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
return authLoginRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
@@ -90,6 +99,7 @@ func completeDomain(toComplete string) []string {
|
||||
return completions
|
||||
}
|
||||
|
||||
// authLoginRun executes the login command logic.
|
||||
func authLoginRun(opts *LoginOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -100,8 +110,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
lang := "zh"
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
|
||||
lang = multi.Apps[0].Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
}
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
@@ -122,18 +134,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
domainSet := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domainSet[p] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domainSet[sc.Service] = true
|
||||
}
|
||||
selectedDomains = make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
selectedDomains = append(selectedDomains, d)
|
||||
}
|
||||
sort.Strings(selectedDomains)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -225,26 +226,37 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
"verification_uri": authResp.VerificationUri,
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
@@ -252,20 +264,26 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
@@ -278,6 +296,8 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
|
||||
// Step 7: Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -295,35 +315,16 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": result.Token.Scope,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if result.Token.Scope != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
|
||||
}
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -336,13 +337,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
|
||||
}
|
||||
cleanupRequestedScope := func() {
|
||||
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
@@ -358,6 +372,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
|
||||
// Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -375,26 +391,57 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return fmt.Errorf("profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
if oldUser.UserOpenId != openID {
|
||||
_ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
@@ -403,11 +450,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
// 2. Expand domains: include auth_domain children
|
||||
domainSet := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
domainSet[d] = true
|
||||
for _, child := range registry.GetAuthChildren(d) {
|
||||
domainSet[child] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
@@ -416,7 +468,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate and sort
|
||||
// 4. Deduplicate and sort
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
@@ -425,14 +477,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domains[p] = true
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
74
cmd/auth/login_config_test.go
Normal file
74
cmd/auth/login_config_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" {
|
||||
t.Fatalf("target users = %#v, want replaced login user", got)
|
||||
}
|
||||
if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" {
|
||||
t.Fatalf("other users = %#v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing profile")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `profile "missing" not found`) {
|
||||
t.Fatalf("error = %v, want missing profile", err)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
@@ -179,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
|
||||
|
||||
// Phase 2: confirmation
|
||||
var confirmed bool
|
||||
form2 := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(msg.ConfirmAuth).
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form2.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
|
||||
return &interactiveResult{
|
||||
Domains: selectedDomains,
|
||||
ScopeLevel: permLevel,
|
||||
|
||||
@@ -20,11 +20,17 @@ type loginMsg struct {
|
||||
ConfirmAuth string
|
||||
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
GrantedScopes string
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
// Non-interactive hint (no flags)
|
||||
HintHeader string
|
||||
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
|
||||
ErrNoDomain: "请至少选择一个业务域",
|
||||
ConfirmAuth: "确认授权?",
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
GrantedScopes: " 已授权 scopes: %s\n",
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
HintHeader: "请指定要授权的权限:\n",
|
||||
HintCommon1: " --recommend 授权推荐权限",
|
||||
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
|
||||
ErrNoDomain: "please select at least one domain",
|
||||
ConfirmAuth: "Confirm authorization?",
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
GrantedScopes: " Granted scopes: %s\n",
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
HintHeader: "Please specify the scopes to authorize:\n",
|
||||
HintCommon1: " --recommend authorize recommended scopes",
|
||||
|
||||
@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// GrantedScopes should contain %s
|
||||
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
|
||||
if got == msg.GrantedScopes {
|
||||
t.Errorf("%s GrantedScopes has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
235
cmd/auth/login_result.go
Normal file
235
cmd/auth/login_result.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type loginScopeSummary struct {
|
||||
Requested []string
|
||||
NewlyGranted []string
|
||||
AlreadyGranted []string
|
||||
Granted []string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
type loginScopeIssue struct {
|
||||
Message string
|
||||
Hint string
|
||||
Summary *loginScopeSummary
|
||||
}
|
||||
|
||||
// ensureRequestedScopesGranted checks whether all requested scopes were granted
|
||||
// and returns a structured issue when any requested scope is missing.
|
||||
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := larkauth.MissingScopes(grantedScope, requested)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: strings.Fields(grantedScope),
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
return &loginScopeIssue{
|
||||
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
|
||||
Hint: msg.ScopeHint,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
|
||||
// previously stored scopes, and the newly granted scopes from the current login.
|
||||
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
|
||||
previousScope := ""
|
||||
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
|
||||
previousScope = previous.Scope
|
||||
}
|
||||
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
|
||||
}
|
||||
|
||||
// buildLoginScopeSummary classifies requested scopes into newly granted,
|
||||
// already granted, and missing buckets while preserving the final granted list.
|
||||
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
previous := uniqueScopeList(previousScope)
|
||||
granted := uniqueScopeList(grantedScope)
|
||||
previousSet := make(map[string]bool, len(previous))
|
||||
for _, scope := range previous {
|
||||
previousSet[scope] = true
|
||||
}
|
||||
grantedSet := make(map[string]bool, len(granted))
|
||||
for _, scope := range granted {
|
||||
grantedSet[scope] = true
|
||||
}
|
||||
|
||||
summary := &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
}
|
||||
for _, scope := range requested {
|
||||
if !grantedSet[scope] {
|
||||
summary.Missing = append(summary.Missing, scope)
|
||||
continue
|
||||
}
|
||||
if previousSet[scope] {
|
||||
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
|
||||
continue
|
||||
}
|
||||
summary.NewlyGranted = append(summary.NewlyGranted, scope)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
|
||||
func uniqueScopeList(scope string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, item := range strings.Fields(scope) {
|
||||
if seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatScopeList joins scopes for display and falls back to the provided empty
|
||||
// label when the input slice is empty.
|
||||
func formatScopeList(scopes []string, empty string) string {
|
||||
if len(scopes) == 0 {
|
||||
return empty
|
||||
}
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
|
||||
func emptyIfNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
// format together with the computed scope breakdown.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
|
||||
if len(summary.Missing) == 0 && msg.StatusHint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
||||
// while preserving a successful login outcome when authorization completed.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
||||
if issue == nil {
|
||||
return nil
|
||||
}
|
||||
loginSucceeded := openId != ""
|
||||
if opts.JSON {
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
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)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
// authorizationCompletePayload builds the JSON payload for a completed login,
|
||||
// optionally attaching a warning when requested scopes are missing.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": strings.Join(summary.Granted, " "),
|
||||
"requested": emptyIfNil(summary.Requested),
|
||||
"newly_granted": emptyIfNil(summary.NewlyGranted),
|
||||
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
||||
"missing": emptyIfNil(summary.Missing),
|
||||
"granted": emptyIfNil(summary.Granted),
|
||||
}
|
||||
if issue != nil {
|
||||
payload["warning"] = map[string]interface{}{
|
||||
"type": "missing_scope",
|
||||
"message": issue.Message,
|
||||
"hint": issue.Hint,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
94
cmd/auth/login_scope_cache.go
Normal file
94
cmd/auth/login_scope_cache.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
type loginScopeCacheRecord struct {
|
||||
RequestedScope string `json:"requested_scope"`
|
||||
}
|
||||
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
func loginScopeCachePath(deviceCode string) string {
|
||||
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
|
||||
func sanitizeLoginScopeCacheKey(deviceCode string) string {
|
||||
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
|
||||
if sanitized == "" {
|
||||
return "default"
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// saveLoginRequestedScope persists the requested scope string for a device_code.
|
||||
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
|
||||
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
|
||||
// It returns an empty string if no cache entry exists.
|
||||
func loadLoginRequestedScope(deviceCode string) (string, error) {
|
||||
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
var record loginScopeCacheRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
_ = vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
return "", err
|
||||
}
|
||||
return record.RequestedScope, nil
|
||||
}
|
||||
|
||||
// removeLoginRequestedScope deletes the cache entry for a device_code.
|
||||
func removeLoginRequestedScope(deviceCode string) error {
|
||||
err := vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
|
||||
// should be removed after polling finishes.
|
||||
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if result.OK || result.Error == "access_denied" {
|
||||
return true
|
||||
}
|
||||
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
|
||||
}
|
||||
51
cmd/auth/login_scope_cache_test.go
Normal file
51
cmd/auth/login_scope_cache_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
deviceCode := "device/code:123"
|
||||
requestedScope := "im:message:send im:message:reply"
|
||||
|
||||
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
got, err := loadLoginRequestedScope(deviceCode)
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != requestedScope {
|
||||
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
|
||||
t.Fatalf("Stat(cachePath) error = %v", err)
|
||||
}
|
||||
if err := removeLoginRequestedScope(deviceCode); err != nil {
|
||||
t.Fatalf("removeLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
got, err := loadLoginRequestedScope("missing-device-code")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("requestedScope = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
81
cmd/auth/login_strict_test.go
Normal file
81
cmd/auth/login_strict_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if called {
|
||||
t.Error("runF should not be called in bot strict mode")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error in bot strict mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called in user strict mode")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called when strict mode is off")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,29 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"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/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
|
||||
func TestSuggestDomain_PrefixMatch(t *testing.T) {
|
||||
known := map[string]bool{
|
||||
"calendar": true,
|
||||
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequestedScopesGranted(t *testing.T) {
|
||||
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
|
||||
if issue == nil {
|
||||
t.Fatal("expected missing scope issue")
|
||||
}
|
||||
if !strings.Contains(issue.Message, "im:message:send") {
|
||||
t.Fatalf("message %q missing requested scope", issue.Message)
|
||||
}
|
||||
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
|
||||
if !strings.Contains(issue.Hint, want) {
|
||||
t.Fatalf("hint %q missing %q", issue.Hint, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
|
||||
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
|
||||
t.Fatalf("Requested = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
|
||||
t.Fatalf("NewlyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if len(summary.Missing) != 0 {
|
||||
t.Fatalf("Missing = %v, want empty", summary.Missing)
|
||||
}
|
||||
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
|
||||
t.Fatalf("Granted = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["scope"] != "im:message:send im:message:reply" {
|
||||
t.Fatalf("scope = %v", data["scope"])
|
||||
}
|
||||
if len(data["newly_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("newly_granted = %#v", data["newly_granted"])
|
||||
}
|
||||
if len(data["already_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("already_granted = %#v", data["already_granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["user_open_id"] != "ou_user" {
|
||||
t.Fatalf("user_open_id = %v", data["user_open_id"])
|
||||
}
|
||||
warning, ok := data["warning"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("warning = %#v", data["warning"])
|
||||
}
|
||||
if warning["type"] != "missing_scope" {
|
||||
t.Fatalf("warning.type = %v", warning["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Granted: []string{"offline_access"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %q in payload: %v", k, data)
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
t.Fatalf("%s = %#v, want JSON array", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
summary *loginScopeSummary
|
||||
expectedPresent []string
|
||||
expectedAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "mixed newly granted and already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send", "contact:user.base:readonly"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scopes are shown",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range tt.expectedPresent {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.expectedAbsent {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
|
||||
t.Fatalf("NewlyGranted = %q, want empty", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(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"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() 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.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,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
stored := larkauth.GetStoredToken("cli_test", "ou_user")
|
||||
if stored == nil {
|
||||
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
|
||||
}
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
|
||||
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
|
||||
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserName != "tester" {
|
||||
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(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"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "im:message:send offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no-wait authLoginRun() error = %v", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
|
||||
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
err = authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("device-code authLoginRun() error = %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send"},
|
||||
})
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
|
||||
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: true, Token: nil}
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
|
||||
t.Fatalf("error = %v, want nil token error", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
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": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
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": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
@@ -290,3 +903,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
if !domains["docs"] {
|
||||
t.Error("docs should still be a known auth domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if strings.HasPrefix(s, "board:whiteboard:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := &multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
30
cmd/bootstrap.go
Normal file
30
cmd/bootstrap.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BootstrapInvocationContext extracts global invocation options before
|
||||
// the real command tree is built, so provider-backed config resolution sees
|
||||
// the correct profile from the start.
|
||||
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
||||
var globals GlobalOptions
|
||||
|
||||
fs := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.SetInterspersed(true)
|
||||
fs.SetOutput(io.Discard)
|
||||
RegisterGlobalFlags(fs, &globals)
|
||||
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
72
cmd/bootstrap_test.go
Normal file
72
cmd/bootstrap_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileEquals(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--profile=target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_IgnoresUnknownFlags(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--verify", "--profile", "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_MissingProfileValue(t *testing.T) {
|
||||
if _, err := BootstrapInvocationContext([]string{"auth", "status", "--profile"}); err == nil {
|
||||
t.Fatal("BootstrapInvocationContext() error = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ShortHelp(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("-h should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--profile + --help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
if value == "lark" {
|
||||
return core.BrandLark
|
||||
}
|
||||
return core.BrandFeishu
|
||||
return core.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,35 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
|
||||
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
type recordingConfigKeychain struct {
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
r.removed = append(r.removed, service+":"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -56,6 +78,60 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -157,3 +233,110 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
t.Fatal("expected factory to be preserved in options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
kc := &recordingConfigKeychain{}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Keychain = kc
|
||||
|
||||
// Make subsequent config saves fail while keeping the existing config readable.
|
||||
if err := os.Chmod(configDir, 0500); err != nil {
|
||||
t.Fatalf("Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
defer os.Chmod(configDir, 0700)
|
||||
|
||||
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected save failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to save config") {
|
||||
t.Fatalf("error = %v, want failed to save config", err)
|
||||
}
|
||||
if len(kc.removed) != 0 {
|
||||
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
|
||||
}
|
||||
|
||||
// Restore permissions and confirm the original config is still intact.
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
|
||||
t.Fatalf("saved config = %#v, want original single app preserved", saved)
|
||||
}
|
||||
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
|
||||
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Fatalf("expected existing config file to remain, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Lang: "zh",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "App Secret") {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
current := multi.Apps[0].DefaultAs
|
||||
current := app.DefaultAs
|
||||
if current == "" {
|
||||
current = "auto"
|
||||
}
|
||||
@@ -39,9 +44,9 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
multi.Apps[0].DefaultAs = value
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,8 @@ type ConfigInitOptions struct {
|
||||
Brand string
|
||||
New bool
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -59,6 +62,7 @@ verification URL from its output.`,
|
||||
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.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -94,6 +98,110 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
return core.SaveMultiAppConfig(config)
|
||||
}
|
||||
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
|
||||
// Clean up old keychain secret and user tokens if AppId changed
|
||||
if multi.Apps[idx].AppId != appId {
|
||||
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
for _, user := range multi.Apps[idx].Users {
|
||||
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = lang
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].Name == profileName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].AppId == appID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = lang
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -117,6 +225,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -124,8 +239,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, opts.AppID)
|
||||
if err := saveAsOnlyApp(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)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -136,8 +250,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
savedLang := ""
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
savedLang = existing.Apps[0].Lang
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
if err != nil {
|
||||
@@ -165,8 +281,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(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)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -191,21 +306,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(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)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
existing.Apps[0].AppId = result.AppID
|
||||
existing.Apps[0].Brand = result.Brand
|
||||
existing.Apps[0].Lang = opts.Lang
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
@@ -224,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(f.IOStreams.In)
|
||||
@@ -296,8 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, resolvedAppId)
|
||||
if err := saveAsOnlyApp(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)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -61,8 +61,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
// Load existing config for defaults
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
var appID, appSecret, brand string
|
||||
@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
|
||||
// Show QR code in terminal
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
// Branch on TTY: human-friendly copy in interactive terminals,
|
||||
// preserve original copy for AI / non-interactive callers.
|
||||
if f.IOStreams.IsTerminal {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
} else {
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
|
||||
// Step 3: Poll for result
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
|
||||
@@ -16,11 +16,16 @@ type initMsg struct {
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
ScanOrOpenLink string
|
||||
WaitingForScan string
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
WaitingForScan string // active polling indicator
|
||||
// Non-TTY (AI / non-interactive) variants — preserve original copy
|
||||
OpenLinkNonTTY string // primary link prompt
|
||||
WaitingForScanNonTTY string // passive waiting indicator
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScan: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
WaitingForScan: "正在获取你的应用配置结果...",
|
||||
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScanNonTTY: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScan: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
WaitingForScan: "Fetching configuration results...",
|
||||
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScanNonTTY: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
|
||||
@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
|
||||
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
|
||||
@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
return output.ErrValidation("not configured yet")
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
// Save empty config
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
|
||||
userCount := 0
|
||||
for _, app := range config.Apps {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -40,12 +42,19 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
|
||||
return nil
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
}
|
||||
app := config.Apps[0]
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
var userStrs []string
|
||||
@@ -55,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
"brand": app.Brand,
|
||||
|
||||
146
cmd/config/strict_mode.go
Normal file
146
cmd/config/strict_mode.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
|
||||
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
var global bool
|
||||
var reset bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
|
||||
// Runtime effective mode from credential provider chain is the source of truth.
|
||||
runtime := f.ResolveStrictMode(ctx)
|
||||
configMode, configSource := resolveStrictModeStatus(multi, app)
|
||||
|
||||
if runtime != configMode {
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
if a.StrictMode != nil && *a.StrictMode != mode {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut,
|
||||
"Warning: profile %q has strict-mode explicitly set to %q, "+
|
||||
"which overrides the global setting. "+
|
||||
"Use --reset in that profile to inherit global.\n",
|
||||
a.ProfileName(), *a.StrictMode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
}
|
||||
if multi.StrictMode.IsActive() {
|
||||
return multi.StrictMode, "global"
|
||||
}
|
||||
return core.StrictModeOff, "global (default)"
|
||||
}
|
||||
164
cmd/config/strict_mode_test.go
Normal file
164
cmd/config/strict_mode_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Show_Default(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "off") {
|
||||
t.Errorf("expected 'off' in output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected StrictMode=bot on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetUser_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"user"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
|
||||
t.Error("expected StrictMode=user on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
|
||||
t.Error("expected StrictMode=off on profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot", "--global"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != core.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Reset(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"--reset"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid value 'on'")
|
||||
}
|
||||
}
|
||||
203
cmd/diagnose_scope_test.go
Normal file
203
cmd/diagnose_scope_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────
|
||||
|
||||
type diagMethodEntry struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"` // "api" or "shortcut"
|
||||
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
|
||||
Scope string `json:"scope"` // minimum-privilege scope
|
||||
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
|
||||
}
|
||||
|
||||
type diagScopeInfo struct {
|
||||
Scope string `json:"scope"`
|
||||
Recommend bool `json:"recommend"`
|
||||
InPriority bool `json:"in_priority"`
|
||||
}
|
||||
|
||||
type diagOutput struct {
|
||||
Methods []diagMethodEntry `json:"methods"`
|
||||
Scopes []diagScopeInfo `json:"scopes"`
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────
|
||||
|
||||
// diagAllKnownDomains returns sorted, deduplicated domain names from both
|
||||
// from_meta projects and shortcuts.
|
||||
func diagAllKnownDomains() []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for d := range seen {
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// methodKey uniquely identifies a method+scope pair for merging identities.
|
||||
type methodKey struct {
|
||||
domain string
|
||||
typ string
|
||||
method string
|
||||
scope string
|
||||
}
|
||||
|
||||
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
|
||||
func diagBuild(domains []string) diagOutput {
|
||||
recommend := registry.LoadAutoApproveSet()
|
||||
identities := []string{"user", "bot"}
|
||||
|
||||
merged := make(map[methodKey]*diagMethodEntry)
|
||||
allSC := shortcuts.AllShortcuts()
|
||||
|
||||
for _, domain := range domains {
|
||||
for _, identity := range identities {
|
||||
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
|
||||
for _, scope := range ce.Scopes {
|
||||
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
|
||||
k := methodKey{domain, "api", method, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "api",
|
||||
Method: method,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]diagMethodEntry, 0, len(merged))
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, e := range merged {
|
||||
methods = append(methods, *e)
|
||||
scopeSet[e.Scope] = true
|
||||
}
|
||||
sort.Slice(methods, func(i, j int) bool {
|
||||
if methods[i].Domain != methods[j].Domain {
|
||||
return methods[i].Domain < methods[j].Domain
|
||||
}
|
||||
if methods[i].Type != methods[j].Type {
|
||||
return methods[i].Type < methods[j].Type
|
||||
}
|
||||
if methods[i].Method != methods[j].Method {
|
||||
return methods[i].Method < methods[j].Method
|
||||
}
|
||||
return methods[i].Scope < methods[j].Scope
|
||||
})
|
||||
|
||||
scopeList := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
scopeList = append(scopeList, s)
|
||||
}
|
||||
sort.Strings(scopeList)
|
||||
|
||||
priorities := registry.LoadScopePriorities()
|
||||
scopes := make([]diagScopeInfo, len(scopeList))
|
||||
for i, s := range scopeList {
|
||||
_, inPri := priorities[s]
|
||||
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
|
||||
}
|
||||
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniq(ss []string, s string) []string {
|
||||
for _, existing := range ss {
|
||||
if existing == s {
|
||||
return ss
|
||||
}
|
||||
}
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
|
||||
func TestScopeSnapshot(t *testing.T) {
|
||||
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
result := diagBuild(diagAllKnownDomains())
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "snapshot.json")
|
||||
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// DoctorOptions holds inputs for the doctor command.
|
||||
@@ -60,6 +62,10 @@ func fail(name, msg, hint string) checkResult {
|
||||
return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint}
|
||||
}
|
||||
|
||||
func warn(name, msg, hint string) checkResult {
|
||||
return checkResult{Name: name, Status: "warn", Message: msg, Hint: hint}
|
||||
}
|
||||
|
||||
func skip(name, msg string) checkResult {
|
||||
return checkResult{Name: name, Status: "skip", Message: msg}
|
||||
}
|
||||
@@ -68,6 +74,12 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
f := opts.Factory
|
||||
var checks []checkResult
|
||||
|
||||
// ── 0. CLI version & update check ──
|
||||
checks = append(checks, pass("cli_version", build.Version))
|
||||
if !opts.Offline {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -214,6 +226,23 @@ func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := update.FetchLatest()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
current := build.Version
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
allOK := true
|
||||
for _, c := range checks {
|
||||
|
||||
17
cmd/global_flags.go
Normal file
17
cmd/global_flags.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
}
|
||||
137
cmd/profile/add.go
Normal file
137
cmd/profile/add.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
appID string
|
||||
appSecretStdin bool
|
||||
brand string
|
||||
lang string
|
||||
use bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileAddRun(f, name, appID, appSecretStdin, brand, lang, use)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "profile name (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().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return output.ErrValidation("profile %q already exists", name)
|
||||
}
|
||||
|
||||
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||
// multiple profiles sharing the same appId would collide on credentials.
|
||||
for _, a := range multi.Apps {
|
||||
if a.AppId == appID {
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
if useAfter {
|
||||
if currentApp := multi.CurrentAppConfig(""); currentApp != nil {
|
||||
previousName = currentApp.ProfileName()
|
||||
}
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
if previousName != "" {
|
||||
multi.PreviousApp = previousName
|
||||
}
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
if useAfter {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q", name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
cmd/profile/list.go
Normal file
85
cmd/profile/list.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
currentName = currentApp.ProfileName()
|
||||
}
|
||||
|
||||
items := make([]profileListItem, 0, len(multi.Apps))
|
||||
for i := range multi.Apps {
|
||||
app := &multi.Apps[i]
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
item.User = app.Users[0].UserName
|
||||
stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId)
|
||||
if stored != nil {
|
||||
item.TokenStatus = larkauth.TokenStatus(stored)
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, items)
|
||||
return nil
|
||||
}
|
||||
29
cmd/profile/profile.go
Normal file
29
cmd/profile/profile.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
|
||||
})
|
||||
|
||||
cmd.AddCommand(NewCmdProfileList(f))
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
cmd.AddCommand(NewCmdProfileRename(f))
|
||||
return cmd
|
||||
}
|
||||
371
cmd/profile/profile_test.go
Normal file
371
cmd/profile/profile_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type failRenameFS struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
}
|
||||
|
||||
func (fs *failRenameFS) Rename(oldpath, newpath string) error {
|
||||
return fs.err
|
||||
}
|
||||
|
||||
func setupProfileConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProfileAddRun_InvalidExistingConfigReturnsError(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)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
|
||||
err := profileAddRun(f, "test", "app-test", true, "feishu", "zh", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid existing config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to load config") {
|
||||
t.Fatalf("error = %v, want failed to load config", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret-new\n")
|
||||
|
||||
if err := profileAddRun(f, "target", "app-target", true, "lark", "en", true); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 2 {
|
||||
t.Fatalf("len(Apps) = %d, want 2", len(saved.Apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
PreviousApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRemoveRun(f, "target"); err != nil {
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "default" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" {
|
||||
t.Fatalf("remaining apps = %#v, want only default", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "new"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "new" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "new")
|
||||
}
|
||||
if saved.PreviousApp != "new" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "new")
|
||||
}
|
||||
if saved.Apps[0].ProfileName() != "new" {
|
||||
t.Fatalf("ProfileName() = %q, want %q", saved.Apps[0].ProfileName(), "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "app-old"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "app-old" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "app-old")
|
||||
}
|
||||
if saved.PreviousApp != "app-old" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "app-old")
|
||||
}
|
||||
if saved.Apps[0].Name != "app-old" {
|
||||
t.Fatalf("Name = %q, want %q", saved.Apps[0].Name, "app-old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
PreviousApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileUseRun(f, "-"); err != nil {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
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)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len(got) = %d, want 0", len(got))
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "old", "new")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
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)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
}
|
||||
}
|
||||
78
cmd/profile/remove.go
Normal file
78
cmd/profile/remove.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRemoveRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
removedName := app.ProfileName()
|
||||
appId := app.AppId
|
||||
appSecret := app.AppSecret
|
||||
users := app.Users
|
||||
|
||||
// Remove from slice
|
||||
multi.Apps = append(multi.Apps[:idx], multi.Apps[idx+1:]...)
|
||||
|
||||
// Fix currentApp / previousApp references
|
||||
if multi.CurrentApp == removedName {
|
||||
multi.CurrentApp = multi.Apps[0].ProfileName()
|
||||
}
|
||||
if multi.PreviousApp == removedName {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/rename.go
Normal file
73
cmd/profile/rename.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRename creates the profile rename subcommand.
|
||||
func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename <old> <new>",
|
||||
Short: "Rename a profile",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
// profile's own appId or current name.
|
||||
for i := range multi.Apps {
|
||||
if i == idx {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
oldProfileName := multi.Apps[idx].ProfileName()
|
||||
multi.Apps[idx].Name = newName
|
||||
|
||||
// Update currentApp / previousApp references
|
||||
if multi.CurrentApp == oldProfileName {
|
||||
multi.CurrentApp = newName
|
||||
}
|
||||
if multi.PreviousApp == oldProfileName {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/use.go
Normal file
73
cmd/profile/use.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileUse creates the profile use subcommand.
|
||||
func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Switch to a profile (use '-' to toggle back)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileUseRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
|
||||
// Short-circuit if already on the target profile
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
if currentApp != nil && currentApp.ProfileName() == targetName {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Already on profile %q\n", targetName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update previous and current
|
||||
if currentApp != nil {
|
||||
multi.PreviousApp = currentApp.ProfileName()
|
||||
}
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||
return nil
|
||||
}
|
||||
80
cmd/prune.go
Normal file
80
cmd/prune.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
|
||||
// pruneIncompatible recursively replaces commands whose annotation declares
|
||||
// identities incompatible with the forced identity. Commands without annotation are kept.
|
||||
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
||||
// of Cobra's generic "unknown flag" fallback from the parent command.
|
||||
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
ids := cmdutil.GetSupportedIdentities(child)
|
||||
if ids != nil && !slices.Contains(ids, forced) {
|
||||
toRemove = append(toRemove, child)
|
||||
toAdd = append(toAdd, strictModeStubFrom(child, mode))
|
||||
continue
|
||||
}
|
||||
pruneIncompatible(child, mode)
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
parent.AddCommand(toAdd...)
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmpty recursively removes group commands (no Run/RunE) that have
|
||||
// no remaining subcommands after pruning. If only hidden stubs remain, keep
|
||||
// the group hidden so direct execution still resolves to the stub path.
|
||||
func pruneEmpty(parent *cobra.Command) {
|
||||
var toRemove []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
pruneEmpty(child)
|
||||
if child.Run != nil || child.RunE != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case child.HasAvailableSubCommands():
|
||||
case len(child.Commands()) > 0:
|
||||
child.Hidden = true
|
||||
default:
|
||||
toRemove = append(toRemove, child)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
}
|
||||
}
|
||||
200
cmd/prune_test.go
Normal file
200
cmd/prune_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
|
||||
noop := func(*cobra.Command, []string) error { return nil }
|
||||
|
||||
userOnly := &cobra.Command{Use: "+search", Short: "user only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
botOnly := &cobra.Command{Use: "+subscribe", Short: "bot only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
|
||||
svc.AddCommand(botOnly)
|
||||
|
||||
dual := &cobra.Command{Use: "+send", Short: "dual", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(dual, []string{"user", "bot"})
|
||||
svc.AddCommand(dual)
|
||||
|
||||
noAnnotation := &cobra.Command{Use: "+legacy", Short: "no annotation", RunE: noop}
|
||||
svc.AddCommand(noAnnotation)
|
||||
|
||||
res := &cobra.Command{Use: "messages"}
|
||||
svc.AddCommand(res)
|
||||
userMethod := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmdutil.SetSupportedIdentities(userMethod, []string{"user"})
|
||||
res.AddCommand(userMethod)
|
||||
|
||||
auth := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(auth)
|
||||
login := &cobra.Command{Use: "login", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(login, []string{"user"})
|
||||
auth.AddCommand(login)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func findCmd(root *cobra.Command, names ...string) *cobra.Command {
|
||||
cmd := root
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
cmd = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+subscribe") == nil {
|
||||
t.Error("+subscribe (bot-only) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+legacy") == nil {
|
||||
t.Error("+legacy (no annotation) should be kept")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "messages", "search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("search (user-only method) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("auth login should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
|
||||
if findCmd(root, "im", "+search") == nil {
|
||||
t.Error("+search (user-only) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "+subscribe"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+subscribe (bot-only) should be replaced by a hidden stub in user mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || cmd.Hidden {
|
||||
t.Error("auth login should be kept in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty_PreservesOriginallyHiddenGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
hidden := &cobra.Command{Use: "hidden", Hidden: true}
|
||||
root.AddCommand(hidden)
|
||||
hidden.AddCommand(&cobra.Command{
|
||||
Use: "visible",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
})
|
||||
|
||||
pruneEmpty(root)
|
||||
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("expected originally hidden group to remain hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "+search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "user"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
106
cmd/root.go
106
cmd/root.go
@@ -4,11 +4,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
@@ -16,14 +19,17 @@ import (
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -40,7 +46,7 @@ EXAMPLES:
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events list --params '{"calendar_id":"primary"}'
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
@@ -58,6 +64,8 @@ FLAGS:
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
@@ -65,13 +73,13 @@ AI AGENT SKILLS:
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli --all -y
|
||||
npx skills add larksuite/cli -g -y
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
@@ -82,8 +90,14 @@ More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
f := cmdutil.NewDefault()
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -92,25 +106,90 @@ func Execute() int {
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupUpdateNotice starts an async update check and wires the output decorator.
|
||||
func setupUpdateNotice() {
|
||||
// Sync: check cache immediately (no network, fast).
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
|
||||
// Async: refresh cache for this run (and future runs).
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(build.Version)
|
||||
// If cache was just populated for the first time, set pending now.
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wire the output decorator so JSON envelopes include "_notice".
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
info := update.GetPending()
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
// Update notifications must be suppressed for these to avoid corrupting
|
||||
// machine-parseable completion output.
|
||||
func isCompletionCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "completion" || arg == "__complete" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
@@ -126,12 +205,11 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
|
||||
// All other structured errors normalize to ExitError.
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) already printed the full API
|
||||
// response to stdout; skip enrichment and duplicate stderr envelope.
|
||||
return exitErr.Code
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
return exitErr.Code
|
||||
}
|
||||
@@ -184,12 +262,18 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
b, err := json.MarshalIndent(env, "", " ")
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
err := encoder.Encode(env)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
490
cmd/root_integration_test.go
Normal file
490
cmd/root_integration_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeRootIntegration runs a command through the full command tree and
|
||||
// handleRootError, returning the exit code matching real CLI behavior.
|
||||
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
targetMode := mode
|
||||
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.BrandFeishu,
|
||||
StrictMode: &targetMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("auth --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "login") {
|
||||
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
resetBuffers(stdout, stderr)
|
||||
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
|
||||
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("im --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "+messages-search") {
|
||||
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+chat-create") {
|
||||
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
||||
// +chat-create supports both user and bot identities, so strict mode user
|
||||
// should allow it and force user identity.
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty stdout for dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -82,9 +82,9 @@ func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should be empty — no envelope written
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String())
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
|
||||
}
|
||||
// The message should NOT have been enriched by enrichPermissionError
|
||||
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
|
||||
@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
@@ -80,6 +86,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
@@ -138,11 +145,25 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -184,7 +205,13 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
}
|
||||
|
||||
// CLI example
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -61,3 +62,123 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -109,7 +109,15 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
@@ -146,10 +154,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
}
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -157,8 +165,19 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
@@ -167,13 +186,20 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
@@ -185,8 +211,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,17 +224,20 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
request, err := buildServiceRequest(opts)
|
||||
request, fileMeta, err := buildServiceRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
@@ -223,7 +255,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -234,32 +266,39 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
result, err := cred.ResolveToken(ctx, credential.NewTokenSpec(identity, config.AppID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored != nil {
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -268,16 +307,12 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// Default: ANY one of the declared scopes is sufficient
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
grantedScopes := make(map[string]bool)
|
||||
for _, s := range strings.Fields(stored.Scope) {
|
||||
grantedScopes[s] = true
|
||||
grantedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedScopes[str] {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -288,19 +323,28 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
|
||||
var params map[string]interface{}
|
||||
if opts.Params != "" {
|
||||
if err := json.Unmarshal([]byte(opts.Params), ¶ms); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
|
||||
}
|
||||
} else {
|
||||
params = map[string]interface{}{}
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
@@ -313,13 +357,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required path parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("%s", err)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
@@ -335,7 +379,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required query parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
@@ -349,22 +393,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
}
|
||||
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
URL: url,
|
||||
Params: queryParams,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload: determine default field name from metadata.
|
||||
defaultField := "file"
|
||||
if len(opts.FileFields) == 1 {
|
||||
defaultField = opts.FileFields[0]
|
||||
}
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
|
||||
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
@@ -400,7 +482,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -44,16 +45,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
return m
|
||||
}
|
||||
|
||||
func tokenStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: "tenant_access_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test", "expire": 7200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
|
||||
func TestRegisterService(t *testing.T) {
|
||||
@@ -318,7 +309,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--params invalid JSON format") {
|
||||
if !strings.Contains(err.Error(), "--params invalid format") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -341,6 +332,24 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both --params and --data use stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
@@ -364,7 +373,6 @@ func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -391,7 +399,6 @@ func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
@@ -425,7 +432,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -455,7 +461,6 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
@@ -474,6 +479,171 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── jq flag ──
|
||||
|
||||
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--jq", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("runF was not called")
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"-q", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
@@ -541,6 +711,144 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --file flag to be registered for file upload method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for non-file method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
getMethod := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "GET",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--file", "image=" + tmpFile,
|
||||
"--data", `{"image_type":"message"}`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file fields",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
|
||||
314
cmd/update/update.go
Normal file
314
cmd/update/update.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
osWindows = "windows"
|
||||
)
|
||||
|
||||
// Overridable for testing.
|
||||
var (
|
||||
fetchLatest = func() (string, error) { return update.FetchLatest() }
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
func releaseURL(version string) string {
|
||||
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
|
||||
}
|
||||
|
||||
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
|
||||
|
||||
// --- Terminal symbols (ASCII fallback on Windows) ---
|
||||
|
||||
func symOK() string {
|
||||
if isWindows() {
|
||||
return "[OK]"
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
|
||||
func symFail() string {
|
||||
if isWindows() {
|
||||
return "[FAIL]"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
func symWarn() string {
|
||||
if isWindows() {
|
||||
return "[WARN]"
|
||||
}
|
||||
return "⚠"
|
||||
}
|
||||
|
||||
func symArrow() string {
|
||||
if isWindows() {
|
||||
return "->"
|
||||
}
|
||||
return "→"
|
||||
}
|
||||
|
||||
// --- Command ---
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update lark-cli to the latest version",
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
|
||||
}
|
||||
|
||||
// 2. Validate version format
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
|
||||
}
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
detect := updater.DetectInstallMethod()
|
||||
|
||||
// 5. --check
|
||||
if opts.Check {
|
||||
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
|
||||
}
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
|
||||
})
|
||||
return output.ErrBare(exitCode)
|
||||
}
|
||||
return output.Errorf(exitCode, errType, "%s", msg)
|
||||
}
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "update_available",
|
||||
"auto_update": canAutoUpdate,
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if canAutoUpdate {
|
||||
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "latest_version": latest,
|
||||
"action": "manual_required",
|
||||
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
if npmResult.Stdout.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
|
||||
}
|
||||
if npmResult.Stderr.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Verify the new binary is functional before proceeding.
|
||||
// If corrupt, restore the previous version from .old.
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort).
|
||||
skillsResult := updater.RunSkillsUpdate()
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": latest,
|
||||
"latest_version": latest, "action": "updated",
|
||||
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
if skillsResult.Err != nil {
|
||||
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
if skillsResult.Err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
851
cmd/update/update_test.go
Normal file
851
cmd/update/update_test.go
Normal file
@@ -0,0 +1,851 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "already_up_to_date"`) {
|
||||
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "already up to date") {
|
||||
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected accurate reason in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--force", "--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
err := cmd.Execute()
|
||||
// cobra silences errors when RunE returns; we just check stdout
|
||||
_ = err
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("expected ok:false in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "network timeout") {
|
||||
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_Human(t *testing.T) {
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
// Suppress cobra's default error printing.
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "not-a-version", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "invalid version") {
|
||||
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "DEV" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "permission denied") {
|
||||
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"hint"`) {
|
||||
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
_ = cmd.Execute()
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update failed") {
|
||||
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Permission denied") {
|
||||
t.Errorf("expected permission hint in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "automatic rollback is unavailable") {
|
||||
t.Errorf("expected unavailable rollback hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "previous version has been restored") {
|
||||
t.Errorf("should not claim restore when no backup is available, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "update_available"`) {
|
||||
t.Errorf("expected update_available action, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true for npm, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned release URL, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "CHANGELOG") {
|
||||
t.Errorf("expected changelog URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Npm(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update") {
|
||||
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Manual(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "manually") {
|
||||
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "lark-cli update` to install") {
|
||||
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
// npm detected (node_modules in path) but npm binary not available
|
||||
mockDetect(t, selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm,
|
||||
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
|
||||
NpmAvailable: false,
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required when npm not found, got: %s", out)
|
||||
}
|
||||
// Must say "npm is not available", not generic "not installed via npm"
|
||||
if !strings.Contains(out, "npm is not available") {
|
||||
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseURL(t *testing.T) {
|
||||
got := releaseURL("2.0.0")
|
||||
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
|
||||
t.Errorf("expected version-pinned URL, got: %s", got)
|
||||
}
|
||||
got2 := releaseURL("v1.5.0")
|
||||
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
|
||||
t.Errorf("expected no double v prefix, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
if strings.Contains(hint, "sudo npm install -g") {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Check_JSON(t *testing.T) {
|
||||
// --check on Windows npm should report auto_update: true (rename trick available).
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Symbols(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
currentOS = "windows"
|
||||
if symOK() != "[OK]" {
|
||||
t.Errorf("expected [OK] on Windows, got: %s", symOK())
|
||||
}
|
||||
if symFail() != "[FAIL]" {
|
||||
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
|
||||
}
|
||||
if symWarn() != "[WARN]" {
|
||||
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
|
||||
}
|
||||
if symArrow() != "->" {
|
||||
t.Errorf("expected -> on Windows, got: %s", symArrow())
|
||||
}
|
||||
|
||||
currentOS = "darwin"
|
||||
if symOK() != "\u2713" {
|
||||
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
|
||||
}
|
||||
if symArrow() != "\u2192" {
|
||||
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Should NOT have skills_warning when skills succeed
|
||||
if strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected no skills_warning on success, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// CLI update should still succeed (ok:true)
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true despite skills failure, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected action:updated despite skills failure, got: %s", out)
|
||||
}
|
||||
// Should have skills_warning with detail
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
// CLI update should still show success
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected CLI success message, got: %s", out)
|
||||
}
|
||||
// Skills warning should be shown
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
if len(got) != 2000 {
|
||||
t.Errorf("expected truncated length 2000, got %d", len(got))
|
||||
}
|
||||
|
||||
short := "hello"
|
||||
got2 := selfupdate.Truncate(short, 2000)
|
||||
if got2 != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", got2)
|
||||
}
|
||||
}
|
||||
116
extension/credential/env/env.go
vendored
Normal file
116
extension/credential/env/env.go
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Provider resolves credentials from environment variables.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "env" }
|
||||
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
acct.DefaultAs = credential.IdentityUser
|
||||
case hasTAT:
|
||||
acct.DefaultAs = credential.IdentityBot
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
envKey = envvars.CliUserAccessToken
|
||||
case credential.TokenTypeTAT:
|
||||
envKey = envvars.CliTenantAccessToken
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
token := os.Getenv(envKey)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
282
extension/credential/env/env_test.go
vendored
Normal file
282
extension/credential/env/env_test.go
vendored
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
if (&Provider{}).Name() != "env" {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, "feishu")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_NeitherSet(t *testing.T) {
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil || acct != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected account, got nil")
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
|
||||
}
|
||||
if acct.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultBrand(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
acct, _ := (&Provider{}).ResolveAccount(context.Background())
|
||||
if acct.Brand != "feishu" {
|
||||
t.Errorf("expected 'feishu', got %q", acct.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_UATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_TATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotSet(t *testing.T) {
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil || tok != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeBot(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeUser(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOff(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "off")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "bot" {
|
||||
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferBothTokens(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid strict mode")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid default-as")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
29
extension/credential/registry.go
Normal file
29
extension/credential/registry.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
providers []Provider
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
func Providers() []Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]Provider, len(providers))
|
||||
copy(result, providers)
|
||||
return result
|
||||
}
|
||||
54
extension/credential/registry_test.go
Normal file
54
extension/credential/registry_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ name string }
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return &Account{AppID: s.name}, nil
|
||||
}
|
||||
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
|
||||
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
|
||||
}
|
||||
|
||||
func TestRegisterAndProviders(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "a"})
|
||||
Register(&stubProvider{name: "b"})
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "a" || got[1].Name() != "b" {
|
||||
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "x"})
|
||||
snap := Providers()
|
||||
Register(&stubProvider{name: "y"})
|
||||
|
||||
if len(snap) != 1 {
|
||||
t.Fatalf("snapshot should not be affected, got %d", len(snap))
|
||||
}
|
||||
}
|
||||
100
extension/credential/types.go
Normal file
100
extension/credential/types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "context"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandLark Brand = "lark"
|
||||
BrandFeishu Brand = "feishu"
|
||||
)
|
||||
|
||||
// NoAppSecret marks that a credential source does not provide a real app secret.
|
||||
// Token-only sources should return this value instead of inventing placeholder text.
|
||||
const NoAppSecret = ""
|
||||
|
||||
// Identity represents the caller identity type.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
IdentityAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IdentitySupport declares which identities a credential source can provide.
|
||||
type IdentitySupport uint8
|
||||
|
||||
const (
|
||||
SupportsUser IdentitySupport = 1 << iota
|
||||
SupportsBot
|
||||
SupportsAll = SupportsUser | SupportsBot
|
||||
)
|
||||
|
||||
// Has reports whether s includes the given flag.
|
||||
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
|
||||
|
||||
// UserOnly returns true if only user identity is supported.
|
||||
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
type Token struct {
|
||||
Value string
|
||||
Scopes string // space-separated; empty = skip scope pre-check
|
||||
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
|
||||
}
|
||||
|
||||
// TokenType represents the kind of access token.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat"
|
||||
TokenTypeTAT TokenType = "tat"
|
||||
)
|
||||
|
||||
// TokenSpec describes what token is needed.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
return "blocked by " + e.Provider + ": " + e.Reason
|
||||
}
|
||||
|
||||
// Provider is the unified interface for credential resolution.
|
||||
//
|
||||
// Flow control uses Go's native mechanisms:
|
||||
// - Handle: return &Account{...}, nil or return &Token{...}, nil
|
||||
// - Skip: return nil, nil
|
||||
// - Block: return nil, &BlockError{...}
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
|
||||
}
|
||||
39
extension/credential/types_test.go
Normal file
39
extension/credential/types_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySupport_Has(t *testing.T) {
|
||||
if !SupportsAll.Has(SupportsUser) {
|
||||
t.Error("SupportsAll should have SupportsUser")
|
||||
}
|
||||
if !SupportsAll.Has(SupportsBot) {
|
||||
t.Error("SupportsAll should have SupportsBot")
|
||||
}
|
||||
if SupportsUser.Has(SupportsBot) {
|
||||
t.Error("SupportsUser should not have SupportsBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_UserOnly(t *testing.T) {
|
||||
if !SupportsUser.UserOnly() {
|
||||
t.Error("SupportsUser.UserOnly() should be true")
|
||||
}
|
||||
if SupportsAll.UserOnly() {
|
||||
t.Error("SupportsAll.UserOnly() should be false")
|
||||
}
|
||||
if IdentitySupport(0).UserOnly() {
|
||||
t.Error("zero value UserOnly() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_BotOnly(t *testing.T) {
|
||||
if !SupportsBot.BotOnly() {
|
||||
t.Error("SupportsBot.BotOnly() should be true")
|
||||
}
|
||||
if SupportsAll.BotOnly() {
|
||||
t.Error("SupportsAll.BotOnly() should be false")
|
||||
}
|
||||
}
|
||||
40
extension/fileio/errors.go
Normal file
40
extension/fileio/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPathValidation indicates the path failed security validation
|
||||
// (traversal, absolute, control chars, symlink escape, etc.).
|
||||
var ErrPathValidation = errors.New("path validation failed")
|
||||
|
||||
// PathValidationError wraps a path validation error.
|
||||
// errors.Is(err, ErrPathValidation) returns true.
|
||||
// errors.Is(err, <original OS error>) also works via the chain.
|
||||
type PathValidationError struct {
|
||||
Err error // original error
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string { return e.Err.Error() }
|
||||
func (e *PathValidationError) Unwrap() []error {
|
||||
return []error{ErrPathValidation, e.Err}
|
||||
}
|
||||
|
||||
// MkdirError indicates parent directory creation failed.
|
||||
// Use errors.As(err, &fileio.MkdirError{}) to match.
|
||||
type MkdirError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *MkdirError) Error() string { return e.Err.Error() }
|
||||
func (e *MkdirError) Unwrap() error { return e.Err }
|
||||
|
||||
// WriteError indicates file write failed.
|
||||
// Use errors.As(err, &fileio.WriteError{}) to match.
|
||||
type WriteError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *WriteError) Error() string { return e.Err.Error() }
|
||||
func (e *WriteError) Unwrap() error { return e.Err }
|
||||
31
extension/fileio/registry.go
Normal file
31
extension/fileio/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
73
extension/fileio/types.go
Normal file
73
extension/fileio/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
Mode() fs.FileMode
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
28
extension/transport/registry.go
Normal file
28
extension/transport/registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a transport Provider.
|
||||
// Later registrations override earlier ones.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
77
extension/transport/registry_test.go
Normal file
77
extension/transport/registry_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubInterceptor struct{}
|
||||
|
||||
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
|
||||
|
||||
func TestGetProvider_NilByDefault(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
if got := GetProvider(); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndGet(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "a"}
|
||||
Register(p)
|
||||
|
||||
got := GetProvider()
|
||||
if got != p {
|
||||
t.Fatalf("expected registered provider, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastRegistrationWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
a := &stubProvider{name: "a"}
|
||||
b := &stubProvider{name: "b"}
|
||||
Register(a)
|
||||
Register(b)
|
||||
|
||||
got := GetProvider()
|
||||
if got != b {
|
||||
t.Fatalf("expected provider b, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "test"}
|
||||
Register(p)
|
||||
|
||||
ic := GetProvider().ResolveInterceptor(context.Background())
|
||||
if ic == nil {
|
||||
t.Fatal("expected non-nil Interceptor")
|
||||
}
|
||||
}
|
||||
32
extension/transport/types.go
Normal file
32
extension/transport/types.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider creates Interceptor instances.
|
||||
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
//
|
||||
// PreRoundTrip is called before the built-in chain. Use it to add custom
|
||||
// headers, rewrite the host, or start trace spans. Built-in decorators run
|
||||
// after this and will override any same-named security headers set here.
|
||||
// The extension must not replace req.Context() — the middleware restores
|
||||
// the original context after PreRoundTrip returns.
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
11
go.mod
11
go.mod
@@ -7,10 +7,14 @@ require (
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.33.0
|
||||
@@ -30,6 +34,7 @@ require (
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
@@ -37,6 +42,7 @@ require (
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -46,9 +52,12 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@@ -61,6 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
@@ -103,6 +107,12 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -47,7 +47,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + "/oauth/v1/app/registration"
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
@@ -66,6 +66,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -129,7 +130,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
const maxPollAttempts = 200
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + "/oauth/v1/app/registration"
|
||||
endpoint := ep.Accounts + PathAppRegistration
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
currentInterval := interval
|
||||
attempts := 0
|
||||
@@ -162,6 +163,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
|
||||
func Test_BuildVerificationURL(t *testing.T) {
|
||||
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
|
||||
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
|
||||
|
||||
41
internal/auth/auth_response_log.go
Normal file
41
internal/auth/auth_response_log.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// logHTTPResponse logs the HTTP response details for an authentication request.
|
||||
// It extracts the request path, status code, and x-tt-logid from the given HTTP response.
|
||||
func logHTTPResponse(resp *http.Response) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := "missing"
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
path = resp.Request.URL.Path
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
// logSDKResponse logs the SDK response details for an authentication request.
|
||||
// It extracts the status code and x-tt-logid from the given API response object.
|
||||
func logSDKResponse(path string, apiResp *larkcore.ApiResp) {
|
||||
if path == "" {
|
||||
path = "missing"
|
||||
}
|
||||
|
||||
if apiResp == nil {
|
||||
keychain.LogAuthResponse(path, 0, "")
|
||||
return
|
||||
}
|
||||
|
||||
keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
@@ -54,8 +54,8 @@ type OAuthEndpoints struct {
|
||||
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization",
|
||||
Token: ep.Open + "/open-apis/authen/v2/oauth/token",
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -179,6 +180,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
@@ -258,6 +260,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
|
||||
// helpers
|
||||
|
||||
// minInt returns the smaller of a or b.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -265,6 +268,7 @@ func minInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// getStr retrieves a string value from a map, returning an empty string if not found or not a string.
|
||||
func getStr(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
@@ -274,6 +278,7 @@ func getStr(m map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number.
|
||||
func getInt(m map[string]interface{}, key string, fallback int) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
|
||||
@@ -4,11 +4,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
@@ -19,6 +28,7 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
|
||||
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandLark)
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
@@ -28,3 +38,137 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly.
|
||||
func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: 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": 5,
|
||||
},
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Tt-Logid": []string{"device-log-id"},
|
||||
},
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "time=2026-04-02T03:04:05Z") {
|
||||
t.Fatalf("expected time in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "path=missing") {
|
||||
t.Fatalf("expected path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=200") {
|
||||
t.Fatalf("expected status=200 in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "x-tt-logid=device-log-id") {
|
||||
t.Fatalf("expected x-tt-logid in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
|
||||
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
|
||||
got := keychain.FormatAuthCmdline([]string{
|
||||
"lark-cli",
|
||||
"auth",
|
||||
"login",
|
||||
"--device-code", "device-code-secret",
|
||||
"--app-secret=top-secret",
|
||||
"--scope", "contact:read",
|
||||
})
|
||||
|
||||
want := "lark-cli auth login ..."
|
||||
if got != want {
|
||||
t.Fatalf("formatAuthCmdline() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
|
||||
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
|
||||
t.Cleanup(restore)
|
||||
|
||||
var resp *http.Response
|
||||
logHTTPResponse(resp)
|
||||
|
||||
if got := buf.String(); got != "" {
|
||||
t.Fatalf("expected no log output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
|
||||
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "status", "--verify"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
logSDKResponse(PathUserInfoV1, nil)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "path="+PathUserInfoV1) {
|
||||
t.Fatalf("expected sdk path in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "status=0") {
|
||||
t.Fatalf("expected zero status in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
|
||||
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
|
||||
}, func() []string {
|
||||
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "auth-error") {
|
||||
t.Fatalf("expected auth-error log entry, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "component=keychain") {
|
||||
t.Fatalf("expected component in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "op=Set") {
|
||||
t.Fatalf("expected op in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") {
|
||||
t.Fatalf("expected quoted error in log, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type NeedAuthorizationError struct {
|
||||
UserOpenId string
|
||||
}
|
||||
|
||||
// Error returns the error message for NeedAuthorizationError.
|
||||
func (e *NeedAuthorizationError) Error() string {
|
||||
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
|
||||
}
|
||||
@@ -44,6 +45,7 @@ type SecurityPolicyError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the error message for SecurityPolicyError.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
|
||||
@@ -51,6 +53,7 @@ func (e *SecurityPolicyError) Error() string {
|
||||
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
23
internal/auth/paths.go
Normal file
23
internal/auth/paths.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
// Common authentication paths used for logging and API calls.
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"
|
||||
// PathUserInfoV1 is the endpoint for fetching user information.
|
||||
PathUserInfoV1 = "/open-apis/authen/v1/user_info"
|
||||
// PathApplicationInfoV6Prefix is the prefix endpoint for fetching application info.
|
||||
PathApplicationInfoV6Prefix = "/open-apis/application/v6/applications/"
|
||||
)
|
||||
|
||||
// ApplicationInfoPath returns the full API path for querying an application's information.
|
||||
func ApplicationInfoPath(appId string) string {
|
||||
return PathApplicationInfoV6Prefix + appId
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMissingScopes tests the calculation of missing scopes.
|
||||
func TestMissingScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -62,6 +63,7 @@ func TestMissingScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// sliceEqual compares two string slices for equality.
|
||||
func sliceEqual(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
|
||||
@@ -25,6 +25,7 @@ type StoredUAToken struct {
|
||||
|
||||
const refreshAheadMs = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// accountKey generates a unique key for an account based on its AppID and UserOpenID.
|
||||
func accountKey(appId, userOpenId string) string {
|
||||
return fmt.Sprintf("%s:%s", appId, userOpenId)
|
||||
}
|
||||
@@ -39,8 +40,8 @@ func MaskToken(token string) string {
|
||||
|
||||
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
|
||||
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
|
||||
jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if jsonStr == "" {
|
||||
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if err != nil || jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var token StoredUAToken
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -19,11 +21,12 @@ type SecurityPolicyTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
// base returns the underlying RoundTripper or http.DefaultTransport if nil.
|
||||
func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return http.DefaultTransport
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
@@ -82,6 +85,7 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
// MCP (JSON-RPC) response format:
|
||||
// {
|
||||
@@ -130,6 +134,7 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryHandleOAPIResponse attempts to parse a standard Lark OpenAPI formatted error response.
|
||||
func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error {
|
||||
// 1. Extract code
|
||||
code := getInt(result, "code", 0)
|
||||
@@ -180,6 +185,7 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidChallengeURL checks if the given URL is a valid challenge URL.
|
||||
func isValidChallengeURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
|
||||
@@ -19,10 +19,12 @@ import (
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// sanitizeID replaces empty IDs with "default" to prevent file path issues.
|
||||
func sanitizeID(id string) string {
|
||||
return safeIDChars.ReplaceAllString(id, "_")
|
||||
}
|
||||
@@ -98,6 +100,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
|
||||
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
|
||||
}
|
||||
|
||||
// refreshWithLock acquires a file lock before attempting to refresh the token.
|
||||
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
|
||||
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
|
||||
|
||||
@@ -126,7 +129,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
|
||||
configDir := core.GetConfigDir()
|
||||
|
||||
lockDir := filepath.Join(configDir, "locks")
|
||||
if err := os.MkdirAll(lockDir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create lock directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
|
||||
return doRefreshToken(httpClient, opts, stored)
|
||||
}
|
||||
|
||||
// doRefreshToken performs the actual HTTP request to refresh the token.
|
||||
func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
|
||||
errOut := opts.ErrOut
|
||||
if errOut == nil {
|
||||
@@ -200,6 +204,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user