mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
refactor/p
...
feat/exter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb68c317a |
13
.github/workflows/arch-audit.yml
vendored
13
.github/workflows/arch-audit.yml
vendored
@@ -62,6 +62,19 @@ jobs:
|
||||
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
|
||||
echo '```' >> report.md
|
||||
|
||||
- name: Circular dependency check
|
||||
run: |
|
||||
echo "## Circular Dependencies" >> report.md
|
||||
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
|
||||
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
|
||||
if [ -s cycles.txt ]; then
|
||||
echo '```' >> report.md
|
||||
cat cycles.txt >> report.md
|
||||
echo '```' >> report.md
|
||||
else
|
||||
echo "No circular dependencies detected." >> report.md
|
||||
fi
|
||||
|
||||
- name: E2E coverage gaps
|
||||
run: |
|
||||
echo "## E2E Coverage Gaps" >> report.md
|
||||
|
||||
128
.github/workflows/ci.yml
vendored
128
.github/workflows/ci.yml
vendored
@@ -82,6 +82,56 @@ jobs:
|
||||
- name: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
extended-integration:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Build both editions and verify identity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go build -o /tmp/lark-cli-standard .
|
||||
go build -tags extended -o /tmp/lark-cli-extended .
|
||||
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
|
||||
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
|
||||
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
|
||||
- name: Cross-compile Extended platform-specific security code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
|
||||
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
|
||||
- name: Verify edition source isolation
|
||||
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
|
||||
- name: Run Extended tests
|
||||
run: make extended-test
|
||||
|
||||
extended-platform-security:
|
||||
needs: fast-gate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run native helper isolation and path trust tests
|
||||
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -119,8 +169,6 @@ jobs:
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Enforce layering ratchet
|
||||
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run source-contract lint guards (lintcheck)
|
||||
@@ -144,6 +192,28 @@ jobs:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
install-only: true
|
||||
- name: Validate GoReleaser configuration
|
||||
run: goreleaser check
|
||||
- name: Check Extended installer syntax
|
||||
shell: pwsh
|
||||
run: |
|
||||
sh -n scripts/install-extended.sh
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path scripts/install-extended.ps1),
|
||||
[ref]$tokens,
|
||||
[ref]$errors
|
||||
) | Out-Null
|
||||
if ($errors.Count -ne 0) {
|
||||
$errors | ForEach-Object { Write-Error $_ }
|
||||
exit 1
|
||||
}
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
@@ -218,16 +288,20 @@ jobs:
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
|
||||
# Extended implementation files are selected by build tags and would
|
||||
# otherwise be absent from the uploaded report. Their race-enabled
|
||||
# suite runs in extended-integration; this pass contributes coverage.
|
||||
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
files: coverage-standard.txt,coverage-extended.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
@@ -237,21 +311,31 @@ jobs:
|
||||
- name: Coverage summary
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [ ! -f coverage.txt ]; then
|
||||
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
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
|
||||
report_coverage() {
|
||||
profile="$1"
|
||||
label="$2"
|
||||
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ ! -f "$profile" ]; then
|
||||
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
return
|
||||
fi
|
||||
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
}
|
||||
report_coverage coverage-standard.txt Standard
|
||||
report_coverage coverage-extended.txt Extended
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
@@ -522,7 +606,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -544,6 +628,8 @@ jobs:
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
@@ -567,7 +653,9 @@ jobs:
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}"; do
|
||||
"${{ needs.license-header.result }}" \
|
||||
"${{ needs.extended-integration.result }}" \
|
||||
"${{ needs.extended-platform-security.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -46,6 +46,8 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -68,7 +70,7 @@ jobs:
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
- name: Build and upload draft release with GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
@@ -80,14 +82,41 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Verify release edition identities
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
|
||||
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
|
||||
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
|
||||
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
|
||||
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
|
||||
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
|
||||
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Verify release platform asset matrix
|
||||
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Attest release archives
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
|
||||
with:
|
||||
subject-path: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/checksums.txt
|
||||
dist/install-extended.sh
|
||||
dist/install-extended.ps1
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
|
||||
dist/install-extended.sh dist/install-extended.ps1 \
|
||||
npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -97,6 +126,76 @@ jobs:
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- name: Publish verified GitHub release
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const { owner, repo } = context.repo;
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const matches = releases.filter((release) => release.tag_name === tag);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
|
||||
}
|
||||
const release = matches[0];
|
||||
if (!release.draft) {
|
||||
throw new Error(`release ${tag} became public before verification completed`);
|
||||
}
|
||||
|
||||
const checksumPath = "dist/checksums.txt";
|
||||
const checksumBody = fs.readFileSync(checksumPath, "utf8");
|
||||
const expectedDigests = new Map();
|
||||
for (const line of checksumBody.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
|
||||
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
|
||||
const name = match[2];
|
||||
if (expectedDigests.has(name)) {
|
||||
throw new Error(`duplicate checksums.txt entry: ${name}`);
|
||||
}
|
||||
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
|
||||
}
|
||||
expectedDigests.set(
|
||||
"checksums.txt",
|
||||
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
|
||||
);
|
||||
|
||||
const actualNames = release.assets.map((asset) => asset.name).sort();
|
||||
const expectedNames = [...expectedDigests.keys()].sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error(
|
||||
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
for (const asset of release.assets) {
|
||||
const expected = expectedDigests.get(asset.name);
|
||||
if (!asset.digest) {
|
||||
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
|
||||
}
|
||||
if (asset.digest.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
draft: false,
|
||||
make_latest: "true",
|
||||
});
|
||||
|
||||
publish-npm:
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -5,7 +5,8 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
- binary: lark-cli
|
||||
- id: standard
|
||||
binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
@@ -18,12 +19,54 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- id: extended
|
||||
binary: lark-cli
|
||||
tags:
|
||||
- extended
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
- id: standard
|
||||
ids:
|
||||
- standard
|
||||
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
- CHANGELOG.md
|
||||
- id: extended
|
||||
ids:
|
||||
- extended
|
||||
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
@@ -31,6 +74,18 @@ archives:
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
extra_files:
|
||||
- glob: ./scripts/install-extended.sh
|
||||
- glob: ./scripts/install-extended.ps1
|
||||
|
||||
release:
|
||||
# Keep assets undiscoverable by releases/latest until the workflow has
|
||||
# independently verified checksums, edition identity, and platform coverage.
|
||||
draft: true
|
||||
replace_existing_draft: true
|
||||
extra_files:
|
||||
- glob: ./scripts/install-extended.sh
|
||||
- glob: ./scripts/install-extended.ps1
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -62,21 +62,10 @@ Both notices recommend the same fix command: `lark-cli update`. The skills notic
|
||||
| `internal/credential/` | Credential provider chain (extension → default) |
|
||||
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
|
||||
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
|
||||
| `brand/` | Brand (feishu/lark) and its endpoint hosts — repo root, so `extension/` may import it |
|
||||
| `internal/workspace/` | Workspace detection plus the config and runtime directory paths |
|
||||
| `internal/identity/` | The `--as` identity (user/bot) and the strict-mode policy |
|
||||
| `internal/config/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/core/config.go` | Multi-profile config loading/saving |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
`internal/core` is gone. Besides the four packages above it also became
|
||||
`internal/secret` (app secret storage and resolution) and `internal/risk` (the
|
||||
read / write / high-risk-write vocabulary). Import the narrowest one you need:
|
||||
`brand`, `internal/workspace`, `internal/identity`, `internal/secret` and
|
||||
`internal/risk` do not import each other — only `internal/config` sits on top of
|
||||
them — so asking for a config directory no longer drags in keychain, i18n and
|
||||
validate.
|
||||
|
||||
## Who Uses This CLI
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
|
||||
|
||||
10
Makefile
10
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -49,8 +49,8 @@ fmt-check:
|
||||
|
||||
script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/check-layering-ratchet.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/release-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
@@ -122,6 +122,12 @@ sidecar-test:
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# extended-test compiles and exercises the separately distributed Extended
|
||||
# edition. The default build remains the Standard npm/npx binary.
|
||||
extended-test:
|
||||
go build -tags extended -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
@@ -13,8 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -34,7 +33,7 @@ type APIOptions struct {
|
||||
// Flags
|
||||
Params string
|
||||
Data string
|
||||
As identity.Identity
|
||||
As core.Identity
|
||||
Output string
|
||||
PageAll bool
|
||||
PageSize int
|
||||
@@ -88,7 +87,7 @@ Examples:
|
||||
opts.Path = args[1]
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = identity.Identity(asStr)
|
||||
opts.As = core.Identity(asStr)
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -305,7 +304,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error {
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,11 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -46,10 +44,10 @@ func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer,
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &configpkg.CliConfig{
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
@@ -64,7 +62,7 @@ func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: identity.AsBot,
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -42,8 +40,8 @@ func newTestRootCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -62,7 +60,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
if gotOpts.Path != "/open-apis/test" {
|
||||
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
|
||||
}
|
||||
if gotOpts.As != identity.AsBot {
|
||||
if gotOpts.As != core.AsBot {
|
||||
t.Errorf("expected as=bot, got %s", gotOpts.As)
|
||||
}
|
||||
if !gotOpts.DryRun {
|
||||
@@ -71,8 +69,8 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -106,8 +104,8 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -124,8 +122,8 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -139,8 +137,8 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register API endpoint stub
|
||||
@@ -172,8 +170,8 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -185,8 +183,8 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -201,8 +199,8 @@ func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -214,8 +212,8 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiValidArgsFunction(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -280,8 +278,8 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -298,8 +296,8 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -318,8 +316,8 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
@@ -334,8 +332,8 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -357,8 +355,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: brand.Feishu,
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -407,8 +405,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: brand.Feishu,
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register a non-batch API that returns scalar data (no array field)
|
||||
@@ -451,8 +449,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Non-batch API that returns a business error (code != 0)
|
||||
@@ -488,8 +486,8 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: brand.Feishu,
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register a batch API that returns an array field
|
||||
@@ -521,8 +519,8 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -563,8 +561,8 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -629,8 +627,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -680,8 +678,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: brand.Feishu,
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -725,8 +723,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -811,8 +809,8 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -831,8 +829,8 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -851,8 +849,8 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
@@ -869,8 +867,8 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -903,8 +901,8 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
@@ -921,8 +919,8 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
@@ -939,8 +937,8 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -970,8 +968,8 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -990,8 +988,8 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
@@ -1009,8 +1007,8 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
@@ -1026,8 +1024,8 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
@@ -1043,8 +1041,8 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
@@ -1066,8 +1064,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
@@ -1104,8 +1102,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
// — there is no raw-payload passthrough; new Lark diagnostic fields require
|
||||
// a CLI release.
|
||||
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "cli_test_perm", AppSecret: "secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1143,8 +1141,8 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
@@ -1196,8 +1194,8 @@ func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]stri
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
@@ -1225,8 +1223,8 @@ func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
@@ -1260,8 +1258,8 @@ func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
@@ -1293,8 +1291,8 @@ func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -30,21 +31,23 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
|
||||
// Pass "auth" as a literal so the error message reads
|
||||
// `"auth" is not supported: ...`
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
login := NewCmdAuthLogin(f, nil)
|
||||
logout := NewCmdAuthLogout(f, nil)
|
||||
status := NewCmdAuthStatus(f, nil)
|
||||
scopes := NewCmdAuthScopes(f, nil)
|
||||
list := NewCmdAuthList(f, nil)
|
||||
check := NewCmdAuthCheck(f, nil)
|
||||
qrcode := NewCmdAuthQRCode(f, nil)
|
||||
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
|
||||
cmdutil.SetRuntimeCapabilities(diagnostic)
|
||||
}
|
||||
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, identity.AsBot)
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -170,7 +173,7 @@ func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory
|
||||
}
|
||||
raw["code"] = code
|
||||
raw["msg"] = msg
|
||||
cc := errclass.ClassifyContext{Identity: string(identity.AsBot)}
|
||||
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
||||
if cfg, _ := f.Config(); cfg != nil {
|
||||
cc.Brand = string(cfg.Brand)
|
||||
cc.AppID = appId
|
||||
|
||||
@@ -12,11 +12,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -24,8 +23,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *LoginOptions
|
||||
@@ -47,8 +46,8 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
|
||||
@@ -73,8 +72,8 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
@@ -93,8 +92,8 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
@@ -193,8 +192,8 @@ func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -212,8 +211,8 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -235,8 +234,8 @@ func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
@@ -337,8 +336,8 @@ func TestDomainFlagCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
@@ -357,8 +356,8 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
@@ -383,8 +382,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
@@ -439,8 +438,8 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
||||
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
||||
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
@@ -531,10 +530,7 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"login", []string{"login"}},
|
||||
{"logout", []string{"logout"}},
|
||||
{"status", []string{"status"}},
|
||||
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
|
||||
{"list", []string{"list"}},
|
||||
{"scopes", []string{"scopes"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -559,3 +555,19 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
|
||||
f := newFactoryWithExternalProvider(t)
|
||||
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := NewCmdAuth(f)
|
||||
matched, _, err := cmd.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
|
||||
t.Fatalf("read-only command blocked: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -33,7 +35,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRun(opts)
|
||||
return authCheckRunContext(cmd.Context(), opts)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,6 +48,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
func authCheckRun(opts *CheckOptions) error {
|
||||
return authCheckRunContext(context.Background(), opts)
|
||||
}
|
||||
|
||||
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
@@ -57,18 +63,74 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.UserOpenId == "" {
|
||||
if f.Credential == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
|
||||
}
|
||||
|
||||
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
|
||||
TokenSpec: credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: config.AppID,
|
||||
},
|
||||
IncludeScopes: true,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to inspect user authorization: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
if inspection == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"credential source returned no authorization inspection")
|
||||
}
|
||||
|
||||
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
if !inspection.Present {
|
||||
if inspection.Source.Managed {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"credential source %q did not provide a user access token", inspection.Source.Name).
|
||||
WithHint("authorize the user through the selected credential source")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
|
||||
missing := larkauth.MissingScopes(stored.Scope, required)
|
||||
switch inspection.ScopeState {
|
||||
case credential.ScopeUnsupported:
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
|
||||
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
|
||||
case credential.ScopeUnknown:
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
|
||||
WithHint("configure the credential source to return trusted scopes for user access tokens")
|
||||
case credential.ScopeKnown:
|
||||
// Continue below.
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
|
||||
}
|
||||
|
||||
suggestion := ""
|
||||
missing := larkauth.MissingScopes(inspection.Scopes, required)
|
||||
if inspection.Source.Managed {
|
||||
if len(missing) > 0 {
|
||||
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
|
||||
}
|
||||
} else {
|
||||
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
|
||||
}
|
||||
|
||||
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
|
||||
missing := larkauth.MissingScopes(availableScopes, required)
|
||||
missingSet := make(map[string]bool, len(missing))
|
||||
for _, s := range missing {
|
||||
missingSet[s] = true
|
||||
@@ -82,8 +144,8 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
if len(missing) > 0 && suggestion != "" {
|
||||
result["suggestion"] = suggestion
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !ok {
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -24,8 +29,8 @@ import (
|
||||
// branch. These tests pin that contract end-to-end through the dispatcher.
|
||||
|
||||
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
// UserOpenId left empty: triggers the not_logged_in branch.
|
||||
})
|
||||
|
||||
@@ -56,8 +61,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user", UserName: "tester",
|
||||
})
|
||||
|
||||
@@ -93,10 +98,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &configpkg.CliConfig{
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
@@ -147,12 +152,134 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type authCheckExternalProvider struct {
|
||||
token *extcred.Token
|
||||
capabilities credential.ProviderCapabilities
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
|
||||
|
||||
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
p.resolveCalls++
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
|
||||
return p.capabilities
|
||||
}
|
||||
|
||||
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
provider := &authCheckExternalProvider{
|
||||
token: token,
|
||||
capabilities: credential.ProviderCapabilities{
|
||||
ProvidesOnDemandAuth: true,
|
||||
CanInspectScopes: canInspectScopes,
|
||||
},
|
||||
}
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
return f, provider
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
|
||||
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
|
||||
Value: "external-uat",
|
||||
Scopes: "im:message docx:document",
|
||||
})
|
||||
stdout := f.IOStreams.Out.(*bytes.Buffer)
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authCheckRun() error = %v", err)
|
||||
}
|
||||
if provider.resolveCalls != 1 {
|
||||
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
|
||||
}
|
||||
granted, ok := payload["granted"].([]any)
|
||||
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
||||
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
|
||||
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
|
||||
Value: "proxy-placeholder",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
|
||||
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
|
||||
}
|
||||
if provider.resolveCalls != 0 {
|
||||
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
|
||||
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
|
||||
Value: "external-uat",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{
|
||||
Factory: f,
|
||||
Scope: "im:message",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
|
||||
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
// Scope validation is a real input error, not a predicate negative
|
||||
// answer — it must surface as a typed ValidationError with the normal
|
||||
// stderr envelope, distinct from the silent ErrBare predicate path.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
func authListRun(opts *ListOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
@@ -61,7 +61,7 @@ func authListRun(opts *ListOptions) error {
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
@@ -69,9 +69,9 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := workspace.CurrentWorkspace()
|
||||
t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) })
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
|
||||
@@ -13,14 +13,12 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -57,7 +55,7 @@ send the verification URL (or QR code) to the user as your final message, end th
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == identity.StrictModeBot {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"strict mode is %q, user login is disabled in this profile", mode).
|
||||
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
@@ -74,7 +72,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand brandpkg.Brand
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
@@ -127,7 +125,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
var lang i18n.Lang
|
||||
if multi, _ := configpkg.LoadMultiAppConfig(); multi != nil {
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
@@ -393,7 +391,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// authLoginPollDeviceCode resumes the device flow by polling with a device code
|
||||
// obtained from a previous --no-wait call.
|
||||
func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
|
||||
func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
|
||||
f := opts.Factory
|
||||
|
||||
httpClient, err := f.HttpClient()
|
||||
@@ -476,7 +474,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, ms
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -486,9 +484,9 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]configpkg.AppUser(nil), app.Users...)
|
||||
app.Users = []configpkg.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -501,7 +499,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *configpkg.AppConfig {
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
@@ -514,7 +512,7 @@ func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *con
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string, brand brandpkg.Brand) []string {
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -555,7 +553,7 @@ func collectScopesForDomains(domains []string, identity string, brand brandpkg.B
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains(brand brandpkg.Brand) map[string]bool {
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -574,7 +572,7 @@ func allKnownDomains(brand brandpkg.Brand) map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains(brand brandpkg.Brand) []string {
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
|
||||
@@ -6,26 +6,26 @@ package auth
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(brand.Feishu)
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(brand.Lark)
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Feishu)
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Lark)
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
@@ -17,22 +17,22 @@ func setupLoginConfigDir(t *testing.T) {
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -54,13 +54,13 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -102,7 +102,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand brandpkg.Brand) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
@@ -25,7 +25,7 @@ type loginScopeCacheRecord struct {
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(workspace.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &configpkg.CliConfig{
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &configpkg.CliConfig{
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
|
||||
@@ -14,10 +14,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -309,8 +308,8 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "secret", Brand: brandpkg.Feishu,
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
// TestFactory has IsTerminal=false by default
|
||||
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
|
||||
@@ -601,21 +600,21 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -697,7 +696,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := configpkg.LoadMultiAppConfig()
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -717,21 +716,21 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -848,15 +847,15 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
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, &configpkg.CliConfig{
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
@@ -887,15 +886,15 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -957,11 +956,11 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
@@ -994,11 +993,11 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1068,11 +1067,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
@@ -1106,11 +1105,11 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brandpkg.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
@@ -45,7 +44,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
func authLogoutRun(opts *LogoutOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
@@ -74,7 +73,7 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := secret.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
@@ -95,8 +94,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []configpkg.AppUser{}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
|
||||
@@ -9,24 +9,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []configpkg.AppUser) {
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: secret.PlainSecret("test-secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
@@ -93,7 +91,7 @@ func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
@@ -129,7 +127,7 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
@@ -155,19 +153,19 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -179,11 +177,11 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -212,7 +210,7 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -226,19 +224,19 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -249,11 +247,11 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -282,7 +280,7 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -296,19 +294,19 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
@@ -320,11 +318,11 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -348,7 +346,7 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -11,15 +11,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
@@ -46,8 +45,8 @@ func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
||||
@@ -32,10 +31,10 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
// and reach the getAppInfoFn call.
|
||||
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: brand.Feishu,
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
return &ScopesOptions{
|
||||
Factory: f,
|
||||
|
||||
@@ -44,6 +44,10 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
|
||||
func authStatusRun(opts *StatusOptions) error {
|
||||
f := opts.Factory
|
||||
editionStatus, err := inspectEditionStatus(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
@@ -64,7 +68,9 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
if !applyEditionStatus(result, diagnostics, editionStatus) {
|
||||
addStatusNote(result, diagnostics)
|
||||
}
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
|
||||
58
cmd/auth/status_edition_extended.go
Normal file
58
cmd/auth/status_edition_extended.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
type editionStatusState struct {
|
||||
provider string
|
||||
variant string
|
||||
}
|
||||
|
||||
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(context.Background())
|
||||
if err != nil {
|
||||
return editionStatusState{}, err
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
state := editionStatusState{provider: source.Name}
|
||||
description := f.RuntimeDescription()
|
||||
if description.Managed {
|
||||
state.variant = description.Variant
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
|
||||
if state.provider == "" {
|
||||
return false
|
||||
}
|
||||
result["source"] = "external"
|
||||
result["credentialProvider"] = state.provider
|
||||
if state.variant != "" {
|
||||
result["externalCredentialMode"] = state.variant
|
||||
}
|
||||
switch {
|
||||
case !diagnostics.User.Available && diagnostics.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
|
||||
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
|
||||
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
|
||||
case !diagnostics.User.Available && !diagnostics.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
|
||||
}
|
||||
return true
|
||||
}
|
||||
54
cmd/auth/status_edition_extended_test.go
Normal file
54
cmd/auth/status_edition_extended_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubExternalProvider{name: "env"}},
|
||||
nil, nil, f.HttpClient,
|
||||
)
|
||||
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
|
||||
Description: runtimeplan.Description{
|
||||
Managed: true,
|
||||
Variant: "managed-test",
|
||||
},
|
||||
}))
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["source"] != "external" ||
|
||||
got["credentialProvider"] != "env" ||
|
||||
got["externalCredentialMode"] != "managed-test" ||
|
||||
got["identity"] != "bot" {
|
||||
t.Fatalf("output = %#v", got)
|
||||
}
|
||||
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
|
||||
!strings.Contains(note, "external credential provider env") {
|
||||
t.Fatalf("note = %q", note)
|
||||
}
|
||||
}
|
||||
21
cmd/auth/status_edition_standard.go
Normal file
21
cmd/auth/status_edition_standard.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
type editionStatusState struct{}
|
||||
|
||||
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
|
||||
return editionStatusState{}, nil
|
||||
}
|
||||
|
||||
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
|
||||
return false
|
||||
}
|
||||
49
cmd/auth/status_edition_standard_test.go
Normal file
49
cmd/auth/status_edition_standard_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubExternalProvider{name: "env"}},
|
||||
nil, nil, f.HttpClient,
|
||||
)
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
|
||||
if _, exists := got[field]; exists {
|
||||
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
var note string
|
||||
if err := json.Unmarshal(got["note"], ¬e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(note, "lark-cli auth login") {
|
||||
t.Fatalf("Standard note = %q, want established login guidance", note)
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,14 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
@@ -36,11 +35,20 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("json.Unmarshal(raw) error = %v", err)
|
||||
}
|
||||
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
|
||||
if _, exists := raw[field]; exists {
|
||||
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
|
||||
49
cmd/build.go
49
cmd/build.go
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
@@ -26,9 +25,11 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/runtimebootstrap"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -44,7 +45,8 @@ type buildConfig struct {
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand brandpkg.Brand
|
||||
startupBrand core.LarkBrand
|
||||
runtime *runtimebootstrap.Result
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
@@ -52,12 +54,20 @@ type buildConfig struct {
|
||||
// registry's sync.Once locks onto the Feishu default at first catalog access,
|
||||
// long before the lazily-resolved config brand is known — see
|
||||
// ResolveStartupBrand for the caller-side resolution.
|
||||
func WithStartupBrand(brand brandpkg.Brand) BuildOption {
|
||||
func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
}
|
||||
}
|
||||
|
||||
// withRuntimeBootstrap shares one invocation snapshot across registry,
|
||||
// credentials, transports, and command capabilities.
|
||||
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.runtime = runtime
|
||||
}
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
// Terminal detection is delegated to cmdutil.NewIOStreams.
|
||||
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
|
||||
@@ -143,9 +153,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
// buildInternal assembles the command tree from one immutable startup
|
||||
// configuration snapshot. Profile selection happens before any registry
|
||||
// network decision and the same result is passed to the Factory.
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
@@ -168,13 +178,29 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
startup := cfg.runtime
|
||||
if startup == nil {
|
||||
startup = runtimebootstrap.Resolve(inv.Profile)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
// Runtime policy can close direct metadata egress before any command is
|
||||
// registered, without exposing a concrete credential mode here.
|
||||
registryBrand := cfg.startupBrand
|
||||
if registryBrand == "" {
|
||||
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
|
||||
}
|
||||
if !startup.Plan.AllowsRemoteMetadata() {
|
||||
if registryBrand == "" {
|
||||
registryBrand = core.BrandFeishu
|
||||
}
|
||||
registry.InitEmbeddedWithBrand(registryBrand)
|
||||
} else if registryBrand != "" {
|
||||
registry.InitWithBrand(registryBrand)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
@@ -220,6 +246,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
registerEditionCommands(rootCmd, f)
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
if !cfg.skipService {
|
||||
|
||||
176
cmd/build_workspace_test.go
Normal file
176
cmd/build_workspace_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
|
||||
previousWorkspace := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workspace core.Workspace
|
||||
signalName string
|
||||
signalValue string
|
||||
expectedAppID string
|
||||
expectedBrand core.LarkBrand
|
||||
}{
|
||||
{
|
||||
name: "local",
|
||||
workspace: core.WorkspaceLocal,
|
||||
expectedAppID: "cli_local",
|
||||
expectedBrand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
name: "openclaw",
|
||||
workspace: core.WorkspaceOpenClaw,
|
||||
signalName: "OPENCLAW_CLI",
|
||||
signalValue: "1",
|
||||
expectedAppID: "cli_openclaw",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
{
|
||||
name: "hermes",
|
||||
workspace: core.WorkspaceHermes,
|
||||
signalName: "HERMES_HOME",
|
||||
signalValue: "/managed/hermes",
|
||||
expectedAppID: "cli_hermes",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
{
|
||||
name: "lark_channel",
|
||||
workspace: core.WorkspaceLarkChannel,
|
||||
signalName: "LARK_CHANNEL",
|
||||
signalValue: "1",
|
||||
expectedAppID: "cli_lark_channel",
|
||||
expectedBrand: core.BrandLark,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearWorkspaceSignals(t)
|
||||
clearCredentialSignals(t)
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
|
||||
t.Setenv(envvars.CliExternalCredentialConfig,
|
||||
filepath.Join(configRoot, "missing-external-credential.json"))
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
if tt.signalName != "" {
|
||||
t.Setenv(tt.signalName, tt.signalValue)
|
||||
}
|
||||
|
||||
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
|
||||
if !tt.workspace.IsLocal() {
|
||||
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
|
||||
}
|
||||
|
||||
// Execute resolves the registry brand before entering
|
||||
// buildInternal. Pin that ordering independently.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if got := selectInvocationWorkspace(); got != tt.workspace {
|
||||
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
|
||||
}
|
||||
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
|
||||
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
|
||||
}
|
||||
|
||||
// Build/buildInternal is also a public construction path. Reset the
|
||||
// process state to local so the test proves it establishes the
|
||||
// workspace before SelectProfile captures the immutable snapshot.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
factory, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), io.Discard, io.Discard),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
)
|
||||
if got := core.CurrentWorkspace(); got != tt.workspace {
|
||||
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
|
||||
}
|
||||
config, err := factory.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.Config() error = %v", err)
|
||||
}
|
||||
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
|
||||
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
|
||||
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearWorkspaceSignals(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"OPENCLAW_CLI",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_SERVICE_MARKER",
|
||||
"OPENCLAW_SERVICE_VERSION",
|
||||
"OPENCLAW_GATEWAY_PORT",
|
||||
"OPENCLAW_SHELL",
|
||||
"HERMES_HOME",
|
||||
"HERMES_QUIET",
|
||||
"HERMES_EXEC_ASK",
|
||||
"HERMES_GATEWAY_TOKEN",
|
||||
"HERMES_SESSION_KEY",
|
||||
"LARK_CHANNEL",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func clearCredentialSignals(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
envvars.CliAppID,
|
||||
envvars.CliAppSecret,
|
||||
envvars.CliBrand,
|
||||
envvars.CliUserAccessToken,
|
||||
envvars.CliTenantAccessToken,
|
||||
envvars.CliDefaultAs,
|
||||
envvars.CliStrictMode,
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func writeWorkspaceProfile(
|
||||
t *testing.T,
|
||||
workspace core.Workspace,
|
||||
name string,
|
||||
appID string,
|
||||
brand core.LarkBrand,
|
||||
) {
|
||||
t.Helper()
|
||||
previous := core.CurrentWorkspace()
|
||||
core.SetCurrentWorkspace(workspace)
|
||||
defer core.SetCurrentWorkspace(previous)
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: name,
|
||||
Apps: []core.AppConfig{{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: core.PlainSecret("test-secret-" + name),
|
||||
Brand: brand,
|
||||
Users: []core.AppUser{},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,12 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// BindOptions holds all inputs for config bind.
|
||||
@@ -131,8 +128,8 @@ func configBindRun(opts *BindOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.SetCurrentWorkspace(workspace.Workspace(source))
|
||||
targetConfigPath := workspace.GetConfigPath()
|
||||
core.SetCurrentWorkspace(core.Workspace(source))
|
||||
targetConfigPath := core.GetConfigPath()
|
||||
|
||||
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
|
||||
if err != nil {
|
||||
@@ -189,12 +186,12 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
}
|
||||
|
||||
var detected string
|
||||
switch workspace.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case workspace.WorkspaceOpenClaw:
|
||||
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case core.WorkspaceOpenClaw:
|
||||
detected = "openclaw"
|
||||
case workspace.WorkspaceHermes:
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
case workspace.WorkspaceLarkChannel:
|
||||
case core.WorkspaceLarkChannel:
|
||||
detected = "lark-channel"
|
||||
}
|
||||
|
||||
@@ -267,7 +264,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
||||
// enumerate candidates, pick one via the shared decision layer, and build a
|
||||
// ready-to-persist AppConfig. Adding a new bind source only requires
|
||||
// implementing SourceBinder — none of the logic below needs to change.
|
||||
func resolveAccount(opts *BindOptions, source string) (*configpkg.AppConfig, error) {
|
||||
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
|
||||
binder, err := newBinder(source, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,12 +307,12 @@ func resolveIdentity(opts *BindOptions) error {
|
||||
// the bind flow treats a corrupt previous config (commitBinding will
|
||||
// overwrite it cleanly).
|
||||
func hasStrictBotLock(data []byte) bool {
|
||||
var multi configpkg.MultiAppConfig
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if app.StrictMode != nil && *app.StrictMode == identity.StrictModeBot {
|
||||
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -372,16 +369,16 @@ func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
||||
return prior
|
||||
}
|
||||
|
||||
func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
sm := identity.StrictModeBot
|
||||
sm := core.StrictModeBot
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = identity.AsBot
|
||||
appConfig.DefaultAs = core.AsBot
|
||||
case "user-default":
|
||||
sm := identity.StrictModeOff
|
||||
sm := core.StrictModeOff
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = identity.AsUser
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
}
|
||||
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
||||
}
|
||||
@@ -392,7 +389,7 @@ func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i
|
||||
// wrong profile's preference into a re-bind when the workspace holds multiple
|
||||
// named profiles and the active one disagrees with Apps[0].
|
||||
func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
var multi configpkg.MultiAppConfig
|
||||
var multi core.MultiAppConfig
|
||||
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -407,10 +404,10 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
// any), and a JSON success envelope. Cleanup runs only after the new config
|
||||
// is durably written — if anything fails earlier, the old workspace stays
|
||||
// usable.
|
||||
func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{*appConfig}}
|
||||
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(workspace.GetConfigDir(), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
@@ -479,8 +476,8 @@ func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousCo
|
||||
// the secret that ForStorage just wrote (old and new secret share the same
|
||||
// keychain key, derived from appId). Best-effort: errors are silently
|
||||
// ignored (same contract as config init's cleanup).
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *configpkg.AppConfig) {
|
||||
var multi configpkg.MultiAppConfig
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -492,7 +489,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *conf
|
||||
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
|
||||
continue
|
||||
}
|
||||
secret.RemoveSecretStore(app.AppSecret, kc)
|
||||
core.RemoveSecretStore(app.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,13 +503,13 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
detected := workspace.DetectWorkspaceFromEnv(os.Getenv)
|
||||
detected := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
switch detected {
|
||||
case workspace.WorkspaceOpenClaw:
|
||||
case core.WorkspaceOpenClaw:
|
||||
source = "openclaw"
|
||||
case workspace.WorkspaceHermes:
|
||||
case core.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
case workspace.WorkspaceLarkChannel:
|
||||
case core.WorkspaceLarkChannel:
|
||||
source = "lark-channel"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
@@ -585,7 +582,7 @@ func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, er
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
if data, err := vfs.ReadFile(configPath); err == nil {
|
||||
var multi configpkg.MultiAppConfig
|
||||
var multi core.MultiAppConfig
|
||||
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
|
||||
app := multi.Apps[0]
|
||||
existingSummary = fmt.Sprintf(msg.ConflictDesc,
|
||||
|
||||
@@ -13,15 +13,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// wantErrDetail is the normalized comparison shape for a typed error's wire
|
||||
@@ -84,8 +80,8 @@ func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) {
|
||||
// Must be called at the start of any test that may trigger configBindRun (which sets workspace).
|
||||
func saveWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := workspace.CurrentWorkspace()
|
||||
t.Cleanup(func() { workspace.SetCurrentWorkspace(orig) })
|
||||
orig := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
|
||||
}
|
||||
|
||||
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
|
||||
@@ -233,7 +229,7 @@ func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
|
||||
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
|
||||
}
|
||||
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -269,7 +265,7 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Fatalf("re-bind (no --lang): %v", err)
|
||||
}
|
||||
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -283,9 +279,9 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||
// workspace (set up via `profile add` before a re-bind), the active profile's
|
||||
// Lang must win over a sibling profile that happens to sit earlier in the slice.
|
||||
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
||||
multi := configpkg.MultiAppConfig{
|
||||
multi := core.MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
|
||||
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
|
||||
},
|
||||
@@ -304,8 +300,8 @@ func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
||||
// so a bind-written config (which always has exactly one app and no
|
||||
// CurrentApp field) still inherits its Lang.
|
||||
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
|
||||
multi := configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
multi := core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{AppId: "cli_only", Lang: i18n.LangJaJP},
|
||||
},
|
||||
}
|
||||
@@ -643,8 +639,8 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
// Brand is not in the stdout envelope — read it back from the persisted
|
||||
// workspace config to verify accounts.app.tenant flowed through to the
|
||||
// stored AppConfig.Brand field.
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
@@ -690,8 +686,8 @@ func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
@@ -805,16 +801,16 @@ func TestConfigShowRun_WorkspaceField(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceLocal)
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "cli_local_test",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
@@ -831,7 +827,7 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
@@ -1002,7 +998,7 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read config.json: %v", err)
|
||||
}
|
||||
var multi configpkg.MultiAppConfig
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
t.Fatalf("unmarshal config.json: %v", err)
|
||||
}
|
||||
@@ -1012,8 +1008,8 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
|
||||
if multi.Apps[0].AppId != "cli_hermes_abc" {
|
||||
t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc")
|
||||
}
|
||||
if multi.Apps[0].Brand != brand.Lark {
|
||||
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, brand.Lark)
|
||||
if multi.Apps[0].Brand != core.BrandLark {
|
||||
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,7 +1275,7 @@ func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) {
|
||||
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")),
|
||||
})
|
||||
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
core.StrictModeBot, core.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default
|
||||
@@ -1314,7 +1310,7 @@ func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) {
|
||||
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")),
|
||||
})
|
||||
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
core.StrictModeBot, core.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the
|
||||
@@ -1410,7 +1406,7 @@ func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) {
|
||||
t.Fatalf("expected --force to allow the escalation, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
identity.StrictModeOff, identity.AsUser)
|
||||
core.StrictModeOff, core.AsUser)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same
|
||||
@@ -1446,7 +1442,7 @@ func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) {
|
||||
t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
identity.StrictModeBot, identity.AsBot)
|
||||
core.StrictModeBot, core.AsBot)
|
||||
}
|
||||
|
||||
// TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the
|
||||
@@ -1483,18 +1479,18 @@ func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) {
|
||||
t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err)
|
||||
}
|
||||
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
||||
identity.StrictModeOff, identity.AsUser)
|
||||
core.StrictModeOff, core.AsUser)
|
||||
}
|
||||
|
||||
// assertPresetApplied verifies the on-disk config.json applied the identity
|
||||
// preset's StrictMode + DefaultAs expansion.
|
||||
func assertPresetApplied(t *testing.T, configPath string, wantStrict identity.StrictMode, wantDefault identity.Identity) {
|
||||
func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", configPath, err)
|
||||
}
|
||||
var multi configpkg.MultiAppConfig
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", configPath, err)
|
||||
}
|
||||
@@ -1791,10 +1787,10 @@ func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) {
|
||||
}
|
||||
|
||||
oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`)
|
||||
newApp := &configpkg.AppConfig{
|
||||
newApp := &core.AppConfig{
|
||||
AppId: "cli_shared",
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: sharedID},
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: sharedID},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1821,10 +1817,10 @@ func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T
|
||||
}
|
||||
|
||||
oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`)
|
||||
newApp := &configpkg.AppConfig{
|
||||
newApp := &core.AppConfig{
|
||||
AppId: "cli_new",
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: newID},
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: newID},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/openclawbind"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -38,7 +36,7 @@ type SourceBinder interface {
|
||||
ListCandidates() ([]Candidate, error)
|
||||
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
|
||||
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
|
||||
Build(appID string) (*configpkg.AppConfig, error)
|
||||
Build(appID string) (*core.AppConfig, error)
|
||||
}
|
||||
|
||||
// newBinder constructs the SourceBinder for the given source name.
|
||||
@@ -140,15 +138,15 @@ type openclawBinder struct {
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read / re-parse.
|
||||
cfg *openclawbind.OpenClawRoot
|
||||
rawApps []openclawbind.CandidateApp
|
||||
cfg *binding.OpenClawRoot
|
||||
rawApps []binding.CandidateApp
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Name() string { return "openclaw" }
|
||||
func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := openclawbind.ReadOpenClawConfig(b.path)
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify OpenClaw is installed and configured").
|
||||
@@ -159,7 +157,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
WithHint("configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := openclawbind.ListCandidateApps(cfg.Channels.Feishu)
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
b.cfg = cfg
|
||||
b.rawApps = raw
|
||||
|
||||
@@ -170,12 +168,12 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *openclawbind.CandidateApp
|
||||
var selected *binding.CandidateApp
|
||||
for i := range b.rawApps {
|
||||
if b.rawApps[i].AppID == appID {
|
||||
selected = &b.rawApps[i]
|
||||
@@ -190,24 +188,24 @@ func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := openclawbind.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := secretpkg.ForStorage(selected.AppID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &configpkg.AppConfig{
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: brand.ParseBrand(selected.Brand),
|
||||
Brand: core.ParseBrand(selected.Brand),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -240,7 +238,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
@@ -253,17 +251,17 @@ func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &configpkg.AppConfig{
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: brand.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -276,14 +274,14 @@ type larkChannelBinder struct {
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read the file.
|
||||
cfg *openclawbind.LarkChannelRoot
|
||||
cfg *binding.LarkChannelRoot
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Name() string { return "lark-channel" }
|
||||
func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := openclawbind.ReadLarkChannelConfig(b.path)
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify lark-channel-bridge is installed and configured").
|
||||
@@ -297,7 +295,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
@@ -311,24 +309,24 @@ func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
|
||||
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := openclawbind.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &configpkg.AppConfig{
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: brand.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -20,10 +20,10 @@ type fakeBinder struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*configpkg.AppConfig, error) { return nil, nil }
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
|
||||
|
||||
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
|
||||
// guardrail that proves the non-TUI decision paths really do stay out of the
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -19,25 +20,41 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
initCmd := NewCmdConfigInit(f, nil)
|
||||
bind := NewCmdConfigBind(f, nil)
|
||||
remove := NewCmdConfigRemove(f, nil)
|
||||
show := NewCmdConfigShow(f, nil)
|
||||
defaultAs := NewCmdConfigDefaultAs(f)
|
||||
strictMode := NewCmdConfigStrictMode(f)
|
||||
riskControl := NewCmdConfigRiskControl(f)
|
||||
policy := NewCmdConfigPolicy(f)
|
||||
plugins := NewCmdConfigPlugins(f)
|
||||
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
|
||||
// Identity preferences live in the Profile, but external providers have
|
||||
// historically treated these config commands as credential management.
|
||||
// Check Profile ownership first so a managed runtime gives the actionable
|
||||
// deployment-managed Profile error, then retain the credential capability
|
||||
// so Standard external-provider behavior stays unchanged.
|
||||
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
|
||||
cmdutil.SetRuntimeCapabilities(
|
||||
identitySetting,
|
||||
runtimeplan.CapabilityLocalProfileMutation,
|
||||
runtimeplan.CapabilityLocalCredentialManagement,
|
||||
)
|
||||
}
|
||||
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
|
||||
cmdutil.SetRuntimeCapabilities(sourceNeutral)
|
||||
}
|
||||
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) brand.Brand {
|
||||
return brand.ParseBrand(value)
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
return core.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -12,16 +12,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -68,8 +67,8 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ConfigShowOptions
|
||||
@@ -110,16 +109,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -188,18 +187,18 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
existing := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{
|
||||
{AppId: "cli_x", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Lang: i18n.LangJaJP},
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
|
||||
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
|
||||
}}
|
||||
if err := configpkg.SaveMultiAppConfig(existing); err != nil {
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if err := saveInitConfig("", existing, f, "cli_x", secret.PlainSecret("s2"), brand.Feishu, ""); err != nil {
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||
}
|
||||
|
||||
got, err := configpkg.LoadMultiAppConfig()
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
@@ -320,17 +319,17 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: secret.SecretInput{
|
||||
Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: brand.Feishu,
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -359,7 +358,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -379,18 +378,18 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", secret.PlainSecret("new-secret"), brand.Lark, "en")
|
||||
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")
|
||||
}
|
||||
@@ -430,21 +429,21 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: secret.SecretInput{Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Lang: "zh",
|
||||
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", brand.Lark, "en")
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
@@ -454,10 +453,16 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
||||
type stubConfigExtProvider struct{ name string }
|
||||
type stubConfigExtProvider struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubConfigExtProvider) Name() string { return s.name }
|
||||
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
@@ -483,7 +488,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
{"show", []string{"show"}},
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
@@ -511,6 +515,63 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
|
||||
profileDenied := errors.New("Profile identity settings are deployment-managed")
|
||||
credentialChecks := 0
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
switch capability {
|
||||
case runtimeplan.CapabilityLocalProfileMutation:
|
||||
return profileDenied
|
||||
case runtimeplan.CapabilityLocalCredentialManagement:
|
||||
credentialChecks++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"default-as", "bot"},
|
||||
{"strict-mode", "bot"},
|
||||
} {
|
||||
t.Run(args[0], func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if !errors.Is(err, profileDenied) {
|
||||
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if credentialChecks != 0 {
|
||||
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := NewCmdConfig(f)
|
||||
|
||||
for _, name := range []string{"default-as", "strict-mode"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
leaf, _, err := root.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := cmdutil.GetRuntimeCapabilities(leaf)
|
||||
want := []runtimeplan.Capability{
|
||||
runtimeplan.CapabilityLocalProfileMutation,
|
||||
runtimeplan.CapabilityLocalCredentialManagement,
|
||||
}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
||||
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
||||
// the same locale; an unrecognized value errors.
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -21,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return configpkg.NoActiveProfileError()
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
@@ -45,8 +44,8 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
app.DefaultAs = identity.Identity(value)
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
|
||||
@@ -13,16 +13,13 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// ConfigInitOptions holds all inputs for config init.
|
||||
@@ -124,7 +121,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := workspace.DetectWorkspaceFromEnv(os.Getenv)
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
@@ -139,7 +136,7 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
|
||||
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
@@ -147,7 +144,7 @@ func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, sk
|
||||
if app.AppId == skipAppID {
|
||||
continue
|
||||
}
|
||||
secretpkg.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
@@ -155,19 +152,19 @@ func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, sk
|
||||
}
|
||||
|
||||
// saveAsOnlyApp overwrites config.json with a single-app config.
|
||||
func saveAsOnlyApp(appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
config := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []configpkg.AppUser{},
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
}},
|
||||
}
|
||||
return configpkg.SaveMultiAppConfig(config)
|
||||
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 *configpkg.MultiAppConfig, f *cmdutil.Factory, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
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)
|
||||
}
|
||||
@@ -198,20 +195,20 @@ func wrapSaveConfigError(err error) error {
|
||||
// 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 *configpkg.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
|
||||
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 = &configpkg.MultiAppConfig{}
|
||||
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 {
|
||||
secretpkg.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
||||
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 = []configpkg.AppUser{}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
@@ -224,19 +221,19 @@ func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAcces
|
||||
WithParam("--name")
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, configpkg.AppConfig{
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []configpkg.AppUser{},
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return configpkg.SaveMultiAppConfig(multi)
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string) int {
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
@@ -248,7 +245,7 @@ func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string)
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *configpkg.MultiAppConfig, appID string) int {
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
@@ -275,13 +272,13 @@ func wrapUpdateExistingProfileErr(err error) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, profileName, appID string, brand brandpkg.Brand, lang string) error {
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
|
||||
var app *configpkg.AppConfig
|
||||
var app *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
@@ -305,7 +302,7 @@ func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, prof
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
||||
return configpkg.SaveMultiAppConfig(existing)
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
@@ -326,14 +323,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := configpkg.LoadMultiAppConfig()
|
||||
existing, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := configpkg.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
@@ -341,14 +338,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
secret, err := secretpkg.ForStorage(opts.AppID, secretpkg.PlainSecret(opts.appSecret), f.Keychain)
|
||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
@@ -380,8 +377,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
}
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
@@ -407,11 +404,11 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
|
||||
if result.AppSecret != "" {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
@@ -446,7 +443,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*configpkg.AppConfig)(nil)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
@@ -497,9 +494,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if resolvedAppId == "" && firstApp != nil {
|
||||
resolvedAppId = firstApp.AppId
|
||||
}
|
||||
var resolvedSecret secretpkg.SecretInput
|
||||
var resolvedSecret core.SecretInput
|
||||
if appSecretInput != "" {
|
||||
resolvedSecret = secretpkg.PlainSecret(appSecretInput)
|
||||
resolvedSecret = core.PlainSecret(appSecretInput)
|
||||
} else if firstApp != nil {
|
||||
resolvedSecret = firstApp.AppSecret
|
||||
}
|
||||
@@ -516,14 +513,14 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
storedSecret, err := secretpkg.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
"net"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ import (
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
type configInitResult struct {
|
||||
Mode string // "create" or "existing"
|
||||
Brand brand.Brand
|
||||
Brand core.LarkBrand
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
@@ -63,8 +62,8 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init
|
||||
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
|
||||
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
|
||||
// Load existing config for defaults
|
||||
existing, _ := configpkg.LoadMultiAppConfig()
|
||||
var firstApp *configpkg.AppConfig
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
@@ -151,8 +150,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride brand.Brand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand brand.Brand
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand core.LarkBrand
|
||||
if brandOverride != "" {
|
||||
larkBrand = brandOverride
|
||||
} else {
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ const probeTimeout = 3 * time.Second
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand brandpkg.Brand) error {
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := brandpkg.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
@@ -132,7 +132,7 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
@@ -148,7 +148,7 @@ func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
@@ -161,7 +161,7 @@ func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
@@ -201,7 +201,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu); err != nil {
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Lark); err != nil {
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
@@ -262,7 +262,7 @@ func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
@@ -275,7 +275,7 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
|
||||
@@ -8,11 +8,9 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
@@ -21,47 +19,47 @@ import (
|
||||
// not for missing user input.
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", brand.Feishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", brand.Feishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
existing := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", brand.Feishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
||||
existing := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: secret.PlainSecret("secret-default"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", brand.Feishu, "en")
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,6 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
|
||||
@@ -16,12 +16,6 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
|
||||
@@ -132,19 +132,16 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
// The policy group explicitly overrides the config parent's local credential
|
||||
// management capability because it is source-neutral diagnostics.
|
||||
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
root := NewCmdConfig(f)
|
||||
leaf, _, err := root.Find([]string{"policy", "show"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
|
||||
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -42,21 +41,21 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := configpkg.LoadMultiAppConfig()
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
||||
}
|
||||
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{}}
|
||||
if err := configpkg.SaveMultiAppConfig(empty); err != nil {
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
secret.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
@@ -23,13 +23,8 @@ func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := configpkg.LoadOrNotConfigured()
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -52,7 +47,7 @@ opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -64,7 +59,7 @@ opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) {
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
|
||||
@@ -8,19 +8,17 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -30,7 +28,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := configpkg.LoadMultiAppConfig()
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -55,7 +53,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = configpkg.LoadMultiAppConfig()
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -68,7 +66,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = configpkg.LoadMultiAppConfig()
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,8 +86,8 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -109,10 +107,10 @@ func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := configpkg.SaveMultiAppConfig(config); err != nil {
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -122,7 +120,7 @@ func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T)
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := configpkg.LoadMultiAppConfig()
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -43,16 +42,26 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
// config show describes the effective invocation configuration, not merely
|
||||
// the bytes in config.json. Preserve the typed bootstrap failure so a
|
||||
// Standard binary cannot present a local Profile as active when the system
|
||||
// requires Extended runtime support.
|
||||
if startupErr := f.RuntimeStartupError(); startupErr != nil {
|
||||
return startupErr
|
||||
}
|
||||
if handled, editionErr := showEditionConfig(f); handled {
|
||||
return editionErr
|
||||
}
|
||||
|
||||
config, err := configpkg.LoadMultiAppConfig()
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return configpkg.NotConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return configpkg.NotConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -67,7 +76,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"workspace": workspace.CurrentWorkspace().Display(),
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
@@ -75,6 +84,6 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
"lang": app.Lang,
|
||||
"users": users,
|
||||
})
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", workspace.GetConfigPath())
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
73
cmd/config/show_edition_extended.go
Normal file
73
cmd/config/show_edition_extended.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type editionConfigShowResult struct {
|
||||
Source string `json:"source"`
|
||||
CredentialProvider string `json:"credentialProvider"`
|
||||
Manageable bool `json:"manageable"`
|
||||
Workspace string `json:"workspace"`
|
||||
AppID string `json:"appId"`
|
||||
Brand string `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Profile *string `json:"profile,omitempty"`
|
||||
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
|
||||
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
|
||||
}
|
||||
|
||||
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(context.Background())
|
||||
if err != nil {
|
||||
return true, typedEditionProviderError("determine the active credential provider", err)
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return false, nil
|
||||
}
|
||||
if source.AppID == "" {
|
||||
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"external credential provider %q returned no account", source.Name)
|
||||
}
|
||||
result := editionConfigShowResult{
|
||||
Source: "external",
|
||||
CredentialProvider: source.Name,
|
||||
Manageable: false,
|
||||
Workspace: core.CurrentWorkspace().Display(),
|
||||
AppID: source.AppID,
|
||||
Brand: string(source.Brand),
|
||||
DefaultAs: string(source.DefaultAs),
|
||||
}
|
||||
description := f.RuntimeDescription()
|
||||
if source.ProfileName != "" {
|
||||
result.Profile = &source.ProfileName
|
||||
}
|
||||
if description.Managed && description.Variant != "" {
|
||||
result.ExternalCredentialMode = &description.Variant
|
||||
if description.ProxiesRequests {
|
||||
result.RemoteEndpoint = &description.DataPlaneEndpoint
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func typedEditionProviderError(action string, err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
|
||||
}
|
||||
93
cmd/config/show_edition_extended_test.go
Normal file
93
cmd/config/show_edition_extended_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
cmd := NewCmdConfig(f)
|
||||
matched, _, err := cmd.Find([]string{"show"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
|
||||
t.Fatalf("config show blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
var stdout bytes.Buffer
|
||||
f.IOStreams.Out = &stdout
|
||||
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
|
||||
Description: runtimeplan.Description{
|
||||
Managed: true,
|
||||
Variant: "managed-test",
|
||||
ProxiesRequests: true,
|
||||
DataPlaneEndpoint: "https://managed.example.test",
|
||||
},
|
||||
}))
|
||||
|
||||
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("configShowRun() error = %v", err)
|
||||
}
|
||||
var got editionConfigShowResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "external" ||
|
||||
got.CredentialProvider != "env" ||
|
||||
got.Manageable ||
|
||||
got.AppID != "test-app" ||
|
||||
got.ExternalCredentialMode == nil ||
|
||||
*got.ExternalCredentialMode != "managed-test" ||
|
||||
got.RemoteEndpoint == nil ||
|
||||
*got.RemoteEndpoint != "https://managed.example.test" {
|
||||
t.Fatalf("output = %#v", got)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := fields["appSecret"]; ok {
|
||||
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
|
||||
}
|
||||
if _, ok := fields["users"]; ok {
|
||||
t.Fatalf("managed output must not invent users: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
providerErr := errors.New("provider failed")
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("error = %#v, want internal/unknown", err)
|
||||
}
|
||||
if !errors.Is(err, providerErr) {
|
||||
t.Fatalf("error does not preserve provider failure: %v", err)
|
||||
}
|
||||
}
|
||||
12
cmd/config/show_edition_standard.go
Normal file
12
cmd/config/show_edition_standard.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package config
|
||||
|
||||
import "github.com/larksuite/cli/internal/cmdutil"
|
||||
|
||||
func showEditionConfig(*cmdutil.Factory) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
54
cmd/config/show_edition_standard_test.go
Normal file
54
cmd/config/show_edition_standard_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok ||
|
||||
problem.Category != errs.CategoryConfig ||
|
||||
problem.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("error = %#v, want established config/not_configured result", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
|
||||
startupErr := errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"system external credential configuration requires the lark-cli Extended edition",
|
||||
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
|
||||
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
|
||||
t,
|
||||
nil,
|
||||
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
|
||||
)
|
||||
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if !errors.Is(err, startupErr) {
|
||||
t.Fatalf("config show error = %v, want original startup error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok ||
|
||||
problem.Category != errs.CategoryValidation ||
|
||||
problem.Subtype != errs.SubtypeFailedPrecondition ||
|
||||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
|
||||
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -38,7 +37,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,20 +45,20 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return configpkg.NoActiveProfileError()
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return configpkg.NoActiveProfileError()
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return configpkg.NoActiveProfileError()
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -72,7 +71,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, global bool, args []string) error {
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
||||
}
|
||||
@@ -80,14 +79,14 @@ func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *c
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) error {
|
||||
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)
|
||||
@@ -100,10 +99,10 @@ func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.Mu
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, value string, global bool) error {
|
||||
mode := identity.StrictMode(value)
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
switch mode {
|
||||
case identity.StrictModeBot, identity.StrictModeUser, identity.StrictModeOff:
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
@@ -119,7 +118,7 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode identity.StrictMode
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
@@ -139,16 +138,16 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return configpkg.NoActiveProfileError()
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if oldMode == identity.StrictModeBot && (mode == identity.StrictModeUser || mode == identity.StrictModeOff) {
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
@@ -163,19 +162,19 @@ func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *con
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *configpkg.AppConfig) *bindMsg {
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) (identity.StrictMode, string) {
|
||||
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 identity.StrictModeOff, "global (default)"
|
||||
return core.StrictModeOff, "global (default)"
|
||||
}
|
||||
|
||||
@@ -7,32 +7,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
Apps: []configpkg.AppConfig{{
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Show_Default(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
@@ -45,37 +42,37 @@ func TestStrictMode_Show_Default(t *testing.T) {
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
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, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeBot {
|
||||
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, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
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, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeUser {
|
||||
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, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
@@ -84,23 +81,23 @@ func TestStrictMode_SetOff_Profile(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeOff {
|
||||
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, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
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, _ := configpkg.LoadMultiAppConfig()
|
||||
if multi.StrictMode != identity.StrictModeBot {
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi.StrictMode != core.StrictModeBot {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
@@ -108,38 +105,38 @@ func TestStrictMode_SetBot_Global(t *testing.T) {
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
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 := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != identity.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, identity.StrictModeBot)
|
||||
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, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
@@ -148,7 +145,7 @@ func TestStrictMode_Reset(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := configpkg.LoadMultiAppConfig()
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
@@ -157,7 +154,7 @@ func TestStrictMode_Reset(t *testing.T) {
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
|
||||
@@ -14,16 +14,14 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// DoctorOptions holds inputs for the doctor command.
|
||||
@@ -86,8 +84,12 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
if handled, editionErr := runEditionDoctor(opts, checks); handled {
|
||||
return editionErr
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := configpkg.LoadMultiAppConfig()
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
@@ -98,7 +100,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
@@ -120,7 +122,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
}
|
||||
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
|
||||
|
||||
ep := brand.ResolveEndpoints(cfg.Brand)
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
@@ -132,8 +134,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
// the source-appropriate remediation. A command here would be redundant.
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep brand.Endpoints) []checkResult {
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
return []checkResult{
|
||||
skip("endpoint_open", "skipped (--offline)"),
|
||||
@@ -217,7 +218,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := update.FetchLatest()
|
||||
latest, err := fetchLatestForEdition()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
@@ -241,7 +242,7 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": workspace.CurrentWorkspace().Display(),
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
|
||||
94
cmd/doctor/doctor_edition_extended.go
Normal file
94
cmd/doctor/doctor_edition_extended.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
|
||||
f := opts.Factory
|
||||
if f == nil || f.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
source, err := f.Credential.InspectSource(opts.Ctx)
|
||||
if err != nil {
|
||||
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
if source == nil || !source.Managed {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
provider := source.Name
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
checks = append(checks,
|
||||
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
|
||||
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
|
||||
)
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("credential_source",
|
||||
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
|
||||
|
||||
description := f.RuntimeDescription()
|
||||
if description.Managed {
|
||||
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
|
||||
} else {
|
||||
checks = append(checks, skip("config_file",
|
||||
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
|
||||
}
|
||||
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
|
||||
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
if description.ProxiesRequests {
|
||||
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
|
||||
} else {
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
|
||||
}
|
||||
return true, finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func editionDiagnosticErrorHint(err error) string {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
return blockErr.Reason
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return cfgErr.Hint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
|
||||
if opts.Offline {
|
||||
return skip("endpoint_external_platform", "skipped (--offline)")
|
||||
}
|
||||
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
|
||||
if verified(diagnostics.User) || verified(diagnostics.Bot) {
|
||||
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
|
||||
}
|
||||
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
|
||||
"check the external credential program and platform logs")
|
||||
}
|
||||
75
cmd/doctor/doctor_edition_extended_test.go
Normal file
75
cmd/doctor/doctor_edition_extended_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
|
||||
verified := true
|
||||
endpoint := "https://credentials.example.com"
|
||||
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
|
||||
User: identitydiag.Identity{Verified: &verified},
|
||||
})
|
||||
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
|
||||
t.Fatalf("check = %#v", got)
|
||||
}
|
||||
|
||||
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
|
||||
if got.Status != "fail" {
|
||||
t.Fatalf("unverified check = %#v, want fail", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
|
||||
}
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_env",
|
||||
SupportedIdentities: extcred.SupportsBot,
|
||||
},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "credential_source", "pass")
|
||||
configCheck := findCheck(t, got.Checks, "config_file")
|
||||
if configCheck.Status != "skip" ||
|
||||
!strings.Contains(configCheck.Message, "local config") ||
|
||||
strings.Contains(configCheck.Message, "config init") {
|
||||
t.Fatalf("config_file = %#v", configCheck)
|
||||
}
|
||||
}
|
||||
24
cmd/doctor/doctor_edition_standard.go
Normal file
24
cmd/doctor/doctor_edition_standard.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
|
||||
if opts == nil || opts.Factory == nil {
|
||||
return false, nil
|
||||
}
|
||||
startupErr := opts.Factory.RuntimeStartupError()
|
||||
if startupErr == nil {
|
||||
return false, nil
|
||||
}
|
||||
hint := ""
|
||||
if problem, ok := errs.ProblemOf(startupErr); ok {
|
||||
hint = problem.Hint
|
||||
}
|
||||
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
|
||||
return true, finishDoctor(opts.Factory, checks)
|
||||
}
|
||||
52
cmd/doctor/doctor_edition_standard_test.go
Normal file
52
cmd/doctor/doctor_edition_standard_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_env", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
|
||||
}
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_env",
|
||||
SupportedIdentities: extcred.SupportsBot,
|
||||
},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatal("doctorRun() = nil, want established missing-config failure")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCheck(t, got.Checks, "config_file", "fail")
|
||||
for _, check := range got.Checks {
|
||||
if check.Name == "credential_source" {
|
||||
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@@ -13,17 +12,15 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdDoctor(f)
|
||||
@@ -90,7 +87,7 @@ func TestFinishDoctor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkChecks_Offline(t *testing.T) {
|
||||
ep := brand.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
|
||||
ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
|
||||
opts := &DoctorOptions{Ctx: context.Background(), Offline: true}
|
||||
checks := networkChecks(opts.Ctx, opts, ep)
|
||||
if len(checks) != 2 {
|
||||
@@ -105,22 +102,22 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: secret.PlainSecret("secret"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
@@ -177,32 +174,66 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type failingDefaultAccountResolver struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatal("doctorRun() = nil, want not-configured failure")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
configCheck := findCheck(t, got.Checks, "config_file")
|
||||
if configCheck.Status != "fail" {
|
||||
t.Fatalf("config_file = %#v, want fail", configCheck)
|
||||
}
|
||||
for _, check := range got.Checks {
|
||||
if check.Name == "credential_source" {
|
||||
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu}},
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*configpkg.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = cred
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
|
||||
12
cmd/doctor/update_extended.go
Normal file
12
cmd/doctor/update_extended.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/internal/extendedupdate"
|
||||
|
||||
func fetchLatestForEdition() (string, error) {
|
||||
return extendedupdate.FetchLatest()
|
||||
}
|
||||
12
cmd/doctor/update_standard.go
Normal file
12
cmd/doctor/update_standard.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package doctor
|
||||
|
||||
import "github.com/larksuite/cli/internal/update"
|
||||
|
||||
func fetchLatestForEdition() (string, error) {
|
||||
return update.FetchLatest()
|
||||
}
|
||||
74
cmd/doctor_startup_standard_test.go
Normal file
74
cmd/doctor_startup_standard_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
|
||||
clearWorkspaceSignals(t)
|
||||
clearCredentialSignals(t)
|
||||
configDir := t.TempDir()
|
||||
systemPath := filepath.Join(configDir, "external-credential.json")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root := Build(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), &stdout, &stderr),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
)
|
||||
root.SetArgs([]string{"doctor", "--offline"})
|
||||
if err := root.ExecuteContext(context.Background()); err == nil {
|
||||
t.Fatal("doctor returned nil, want failed diagnostic result")
|
||||
}
|
||||
|
||||
var report struct {
|
||||
Checks []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
} `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
||||
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
if check.Name != "credential_source" {
|
||||
continue
|
||||
}
|
||||
if check.Status != "fail" ||
|
||||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
|
||||
!strings.Contains(check.Hint, "install lark-cli Extended") {
|
||||
t.Fatalf("credential_source check = %#v", check)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "config init") {
|
||||
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
|
||||
}
|
||||
17
cmd/edition_commands_extended.go
Normal file
17
cmd/edition_commands_extended.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
cmdversion "github.com/larksuite/cli/cmd/version"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// registerEditionCommands owns the Extended-only command surface.
|
||||
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
|
||||
root.AddCommand(cmdversion.NewCmdVersion(f))
|
||||
}
|
||||
28
cmd/edition_commands_extended_test.go
Normal file
28
cmd/edition_commands_extended_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExtendedRegistersVersionCommand(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
registerEditionCommands(root, f)
|
||||
|
||||
commands := root.Commands()
|
||||
if len(commands) != 1 || commands[0].Name() != "version" {
|
||||
t.Fatalf("Extended edition commands = %v, want [version]", commands)
|
||||
}
|
||||
if commands[0].Hidden {
|
||||
t.Fatal("Extended version command must be visible")
|
||||
}
|
||||
}
|
||||
20
cmd/edition_commands_standard.go
Normal file
20
cmd/edition_commands_standard.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
cmdversion "github.com/larksuite/cli/cmd/version"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// registerEditionCommands keeps the release identity probe callable in
|
||||
// Standard while cmd/version hides it from help. This preserves the ordinary
|
||||
// command surface and gives installers/CI one edition-neutral verification
|
||||
// contract.
|
||||
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
|
||||
root.AddCommand(cmdversion.NewCmdVersion(f))
|
||||
}
|
||||
28
cmd/edition_commands_standard_test.go
Normal file
28
cmd/edition_commands_standard_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
registerEditionCommands(root, f)
|
||||
|
||||
commands := root.Commands()
|
||||
if len(commands) != 1 || commands[0].Name() != "version" {
|
||||
t.Fatalf("Standard edition commands = %v, want [version]", commands)
|
||||
}
|
||||
if !commands[0].Hidden {
|
||||
t.Fatal("Standard version command must remain hidden")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -58,9 +58,9 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(identitypkg.AsUser)
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
if identity != string(identitypkg.AsUser) && identity != string(identitypkg.AsBot) {
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(identitypkg.AsUser)}
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
@@ -35,7 +35,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
@@ -25,8 +24,8 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: brand.Feishu,
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
@@ -10,9 +10,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
@@ -68,23 +67,23 @@ func encodeAddons(a ManifestAddons) (string, error) {
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand brandpkg.Brand, appID string, a ManifestAddons) (string, error) {
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := brandpkg.ResolveEndpoints(brand).Open
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand brandpkg.Brand, appID string) string {
|
||||
host := brandpkg.ResolveEndpoints(brand).Open
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string {
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
@@ -95,7 +94,7 @@ func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity identitypkg.Identity, missing []string) ManifestAddons {
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
@@ -107,7 +106,7 @@ func missingScopeAddons(identity identitypkg.Identity, missing []string) Manifes
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity identitypkg.Identity, missing []string) ManifestAddons {
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
@@ -56,11 +55,11 @@ func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(brand.Feishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := brand.ResolveEndpoints(brand.Feishu).Open
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
@@ -72,22 +71,22 @@ func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(identity.AsBot, []string{"im:message"})
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(identity.AsUser, []string{"im:message"})
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"})
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, identity.AsBot, []string{"card.action.trigger"})
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
@@ -97,9 +96,9 @@ func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(identity.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(identity.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"}),
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
|
||||
@@ -16,16 +16,15 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -119,7 +118,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := brandpkg.ResolveEndpoints(cfg.Brand).Open
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
@@ -132,7 +131,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: identitypkg.AsBot}
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
@@ -225,8 +224,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (identitypkg.Identity, error) {
|
||||
flagAs := identitypkg.Identity(cmd.Flag("as").Value.String())
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
@@ -239,9 +238,9 @@ func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.Ke
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand brandpkg.Brand
|
||||
brand core.LarkBrand
|
||||
eventKey string
|
||||
identity identitypkg.Identity
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
@@ -265,7 +264,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == identitypkg.AsUser:
|
||||
case pf.identity == core.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
@@ -292,7 +291,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand brandpkg.Brand, appID string, identity identitypkg.Identity, missing []string) string {
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
@@ -369,7 +368,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identitypkg.AsBot, appID))
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -16,14 +17,31 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
|
||||
// Without SilenceUsage, RunE errors print the full flag help banner.
|
||||
SilenceUsage: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
// This hook shadows root's PersistentPreRun, so preserve the matched
|
||||
// command for structured error and declared-scope hints.
|
||||
f.CurrentCommand = cmd
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
|
||||
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
consume := NewCmdConsume(f)
|
||||
bus := NewCmdBus(f)
|
||||
list := NewCmdList(f)
|
||||
schema := NewCmdSchema(f)
|
||||
status := NewCmdStatus(f)
|
||||
stop := NewCmdStop(f)
|
||||
for _, local := range []*cobra.Command{list, schema, status, stop} {
|
||||
cmdutil.SetRuntimeCapabilities(local)
|
||||
}
|
||||
cmd.AddCommand(consume)
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(schema)
|
||||
cmd.AddCommand(status)
|
||||
cmd.AddCommand(stop)
|
||||
cmd.AddCommand(bus)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
146
cmd/event/external_credential_guard_test.go
Normal file
146
cmd/event/external_credential_guard_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_runtime_event_test",
|
||||
AppSecret: "must-not-be-used",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"real-time events are unavailable in this runtime").
|
||||
WithHint("use a runtime that supports real-time events")
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
if capability == runtimeplan.CapabilityRealtimeEvents {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
|
||||
cmd := NewCmdEvents(f)
|
||||
args := []string{"consume", "guarded-before-event-lookup"}
|
||||
matched, _, err := cmd.Find(args)
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
cmd.SetArgs(args)
|
||||
requireExternalEventGuard(t, cmd.Execute())
|
||||
if f.CurrentCommand != matched {
|
||||
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
|
||||
cmd := NewCmdEvents(f)
|
||||
args := []string{"_bus"}
|
||||
matched, _, err := cmd.Find(args)
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
cmd.SetArgs(args)
|
||||
requireExternalEventGuard(t, cmd.Execute())
|
||||
if f.CurrentCommand != matched {
|
||||
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
|
||||
}
|
||||
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("event bus created runtime files before guard: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdEvents(f)
|
||||
|
||||
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
|
||||
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
|
||||
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
|
||||
}
|
||||
|
||||
wantRealtime := map[string]bool{
|
||||
"_bus": true,
|
||||
"consume": true,
|
||||
"list": false,
|
||||
"schema": false,
|
||||
"status": false,
|
||||
"stop": false,
|
||||
}
|
||||
children := make(map[string]*cobra.Command, len(wantRealtime))
|
||||
for _, child := range cmd.Commands() {
|
||||
name := child.Name()
|
||||
want, ok := wantRealtime[name]
|
||||
if !ok {
|
||||
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
|
||||
}
|
||||
children[name] = child
|
||||
got := cmdutil.GetRuntimeCapabilities(child)
|
||||
if want {
|
||||
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
|
||||
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
|
||||
}
|
||||
}
|
||||
if len(children) != len(wantRealtime) {
|
||||
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
|
||||
}
|
||||
|
||||
// Clearing the parent declaration must also clear both consumers. This
|
||||
// proves they inherit the fail-closed default instead of duplicating a
|
||||
// leaf annotation that future event commands could forget.
|
||||
cmdutil.SetRuntimeCapabilities(cmd)
|
||||
for _, name := range []string{"consume", "_bus"} {
|
||||
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
|
||||
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireExternalEventGuard(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed problem", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s",
|
||||
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
t.Fatalf("param = %q, want empty", validationErr.Param)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatal("hint is empty")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -287,7 +287,7 @@ func errorAs(err error, target interface{}) bool {
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
@@ -29,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
@@ -53,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
identitypkg "github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand brandpkg.Brand, identity identitypkg.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
@@ -109,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
@@ -125,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
@@ -137,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
@@ -170,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)); err != nil {
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -178,9 +177,9 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: brandpkg.Feishu,
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.AsBot,
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
@@ -207,9 +206,9 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: brandpkg.Feishu,
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.AsBot,
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
@@ -228,9 +227,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: brandpkg.Feishu,
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.AsBot,
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
@@ -250,9 +249,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: brandpkg.Feishu,
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: identitypkg.AsBot,
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
@@ -267,12 +266,12 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsBot, []string{"im:message"})
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsUser, []string{"im:message"})
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity identity.Identity
|
||||
accessIdentity core.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
|
||||
@@ -14,12 +14,10 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
@@ -47,9 +45,9 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu},
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: identity.AsBot,
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
@@ -43,7 +43,7 @@ type approvalSchemaJSONProperty struct {
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
@@ -63,7 +63,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
@@ -83,7 +83,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
@@ -99,7 +99,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -120,7 +120,7 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -154,7 +154,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -193,7 +193,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -241,7 +241,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
@@ -288,7 +288,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
@@ -334,7 +334,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
// until at least two profiles exist. Intended for the Execute entry point —
|
||||
// buildInternal must not call this directly to stay state-free.
|
||||
func isSingleAppMode() bool {
|
||||
raw, err := configpkg.LoadMultiAppConfig()
|
||||
raw, err := core.LoadMultiAppConfig()
|
||||
if err != nil || raw == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -60,8 +58,8 @@ func TestIsSingleAppMode_NoConfig(t *testing.T) {
|
||||
|
||||
func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true for single-app config")
|
||||
@@ -70,9 +68,9 @@ func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
|
||||
func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []configpkg.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: secret.PlainSecret("y"), Brand: brand.Feishu},
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
|
||||
@@ -103,10 +101,10 @@ func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func saveAppsForTest(t *testing.T, apps []configpkg.AppConfig) {
|
||||
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
|
||||
t.Helper()
|
||||
multi := &configpkg.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
@@ -261,7 +261,7 @@ func splitCSV(s string) []string {
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// workspace.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
@@ -271,7 +271,7 @@ func splitCSV(s string) []string {
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(workspace.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
|
||||
@@ -12,13 +12,11 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
brandpkg "github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
@@ -55,7 +53,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := configpkg.ValidateProfileName(name); err != nil {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
|
||||
WithCause(err).
|
||||
WithParam("--name")
|
||||
@@ -92,12 +90,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
multi = &configpkg.MultiAppConfig{}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
@@ -117,12 +115,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), f.Keychain)
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
parsedBrand := brandpkg.ParseBrand(brand)
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
@@ -133,13 +131,13 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, configpkg.AppConfig{
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []configpkg.AppUser{},
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
@@ -149,7 +147,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,21 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"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 brand.Brand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
@@ -41,7 +40,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := configpkg.LoadMultiAppConfig()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
@@ -14,13 +15,26 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// A child PersistentPreRunE shadows root's PersistentPreRun, so retain
|
||||
// the invocation state used by structured error hints here.
|
||||
cmd.SilenceUsage = true
|
||||
f.CurrentCommand = cmd
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalProfileMutation)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
|
||||
})
|
||||
|
||||
cmd.AddCommand(NewCmdProfileList(f))
|
||||
list := NewCmdProfileList(f)
|
||||
// Listing profiles is read-only and remains useful for diagnostics under a
|
||||
// managed credential runtime. Every other profile subcommand mutates local
|
||||
// profile selection, config, or keychain state and inherits the parent gate.
|
||||
cmdutil.SetRuntimeCapabilities(list)
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
|
||||
@@ -11,13 +11,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -77,7 +75,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
|
||||
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
|
||||
}
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -94,7 +92,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
saved, _ := configpkg.LoadMultiAppConfig()
|
||||
saved, _ := core.LoadMultiAppConfig()
|
||||
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
|
||||
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
|
||||
}
|
||||
@@ -117,13 +115,13 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -134,7 +132,7 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -151,15 +149,15 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
PreviousApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -168,7 +166,7 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -185,17 +183,17 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -204,7 +202,7 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -221,17 +219,17 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -240,7 +238,7 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -257,15 +255,15 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
PreviousApp: "target",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -274,7 +272,7 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := configpkg.LoadMultiAppConfig()
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
@@ -288,14 +286,14 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -341,14 +339,14 @@ func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -366,16 +364,16 @@ func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []configpkg.AppConfig{{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: secret.PlainSecret("secret-old"),
|
||||
Brand: brand.Feishu,
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -393,14 +391,14 @@ func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -463,14 +461,14 @@ func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wa
|
||||
|
||||
func saveTwoProfiles(t *testing.T) {
|
||||
t.Helper()
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
|
||||
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 := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -611,13 +609,13 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
|
||||
t.Run("cannot remove the only profile", func(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &configpkg.MultiAppConfig{
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "solo",
|
||||
Apps: []configpkg.AppConfig{
|
||||
{Name: "solo", AppId: "app-solo", AppSecret: secret.PlainSecret("secret-solo"), Brand: brand.Feishu},
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
@@ -35,7 +34,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,12 +66,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
secret.RemoveSecretStore(appSecret, f.Keychain)
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -30,11 +30,11 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := configpkg.ValidateProfileName(newName); err != nil {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
|
||||
222
cmd/profile/runtime_capabilities_test.go
Normal file
222
cmd/profile/runtime_capabilities_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type recordingProfileKeychain struct {
|
||||
gets int
|
||||
sets int
|
||||
removes int
|
||||
}
|
||||
|
||||
func (k *recordingProfileKeychain) Get(_, _ string) (string, error) {
|
||||
k.gets++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (k *recordingProfileKeychain) Set(_, _, _ string) error {
|
||||
k.sets++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *recordingProfileKeychain) Remove(_, _ string) error {
|
||||
k.removes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProfileMutationCommandsAreDeniedBeforeLocalStateChanges(t *testing.T) {
|
||||
denied := errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"local credential management is unavailable in this runtime",
|
||||
).WithHint("manage credentials through the active provider")
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
if capability == runtimeplan.CapabilityLocalProfileMutation {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
args: []string{"add", "--name", "new", "--app-id", "app-new", "--app-secret-stdin"},
|
||||
},
|
||||
{
|
||||
name: "use",
|
||||
args: []string{"use", "target"},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
args: []string{"rename", "target", "renamed"},
|
||||
},
|
||||
{
|
||||
name: "remove",
|
||||
args: []string{"remove", "target"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configDir := setupProfileConfigDir(t)
|
||||
saveManagedGateFixture(t)
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
before, err := vfs.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(before) error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
|
||||
f.IOStreams.In = strings.NewReader("must-not-be-read\n")
|
||||
keychain := &recordingProfileKeychain{}
|
||||
f.Keychain = keychain
|
||||
|
||||
cmd := NewCmdProfile(f)
|
||||
cmd.SetArgs(tt.args)
|
||||
err = cmd.Execute()
|
||||
if !errors.Is(err, denied) {
|
||||
t.Fatalf("Execute() error = %v, want denied runtime error", err)
|
||||
}
|
||||
|
||||
after, readErr := vfs.ReadFile(configPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(after) error = %v", readErr)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatalf("config changed despite runtime denial:\nbefore: %s\nafter: %s", before, after)
|
||||
}
|
||||
if keychain.gets != 0 || keychain.sets != 0 || keychain.removes != 0 {
|
||||
t.Fatalf("keychain calls = get:%d set:%d remove:%d, want none",
|
||||
keychain.gets, keychain.sets, keychain.removes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRemainsAvailableWhenLocalMutationIsDenied(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveManagedGateFixture(t)
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
if capability == runtimeplan.CapabilityLocalProfileMutation {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"local credential management is unavailable in this runtime",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
|
||||
|
||||
cmd := NewCmdProfile(f)
|
||||
cmd.SetArgs([]string{"list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list was blocked by mutation capability: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"name": "default"`) {
|
||||
t.Fatalf("profile list output = %s, want default profile", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMutationCommandsRemainAvailableByDefault(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
saveManagedGateFixture(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
// origin/main allows Profile preparation while an environment/extension
|
||||
// provider is active. The managed runtime blocks this through its explicit
|
||||
// plan policy; generic provider ownership must not change Standard.
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{profileEnvironmentProvider{}},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
cmd := NewCmdProfile(f)
|
||||
args := []string{"use", "target"}
|
||||
matched, _, err := cmd.Find(args)
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use with default runtime plan error = %v", err)
|
||||
}
|
||||
if f.CurrentCommand != matched {
|
||||
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" || saved.PreviousApp != "default" {
|
||||
t.Fatalf("selection = current:%q previous:%q, want target/default",
|
||||
saved.CurrentApp, saved.PreviousApp)
|
||||
}
|
||||
}
|
||||
|
||||
type profileEnvironmentProvider struct{}
|
||||
|
||||
func (profileEnvironmentProvider) Name() string { return "env" }
|
||||
|
||||
func (profileEnvironmentProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{
|
||||
AppID: "cli_environment",
|
||||
Brand: extcred.BrandFeishu,
|
||||
SupportedIdentities: extcred.SupportsAll,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (profileEnvironmentProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: "environment-token"}, nil
|
||||
}
|
||||
|
||||
func saveManagedGateFixture(t *testing.T) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{
|
||||
Source: "keychain",
|
||||
ID: "appsecret:app-target",
|
||||
}},
|
||||
Brand: core.BrandLark,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
configpkg "github.com/larksuite/cli/internal/config"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := configpkg.LoadOrNotConfigured()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode identity.StrictMode) {
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func pruneForStrictMode(root *cobra.Command, mode identity.StrictMode) {
|
||||
// identities incompatible with the forced identity. Commands without annotation are kept.
|
||||
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
||||
// of Cobra's generic "unknown flag" fallback from the parent command.
|
||||
func pruneIncompatible(parent *cobra.Command, mode identity.StrictMode) {
|
||||
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
@@ -44,7 +44,7 @@ func pruneIncompatible(parent *cobra.Command, mode identity.StrictMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode identity.StrictMode) *cobra.Command {
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
// The denial annotations let the hook layer's populateInvocationDenial
|
||||
// recognise this command as denied, so the Wrap chain is physically
|
||||
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user