mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
48 Commits
feat/exter
...
feat/event
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ac2fa9ed | ||
|
|
88a93254c0 | ||
|
|
569a5d1c5c | ||
|
|
0ab9b1ab90 | ||
|
|
8a5701ddcf | ||
|
|
b44aaa1cfe | ||
|
|
db009030ed | ||
|
|
f6d7c1d601 | ||
|
|
f6f77817e3 | ||
|
|
1da3223403 | ||
|
|
5f43dd7b4a | ||
|
|
4e07c6b01e | ||
|
|
c3e84cdf3a | ||
|
|
faa822ecdb | ||
|
|
f7a821b572 | ||
|
|
dafcaad003 | ||
|
|
e83a24f3fc | ||
|
|
085765fb26 | ||
|
|
9a9f8da699 | ||
|
|
22c54f119b | ||
|
|
cfa862f8d0 | ||
|
|
1a9ee4a4d9 | ||
|
|
83873a2215 | ||
|
|
77c8255f2c | ||
|
|
5a3b112fa7 | ||
|
|
a5d30fe7dc | ||
|
|
0599501680 | ||
|
|
42897d3c5c | ||
|
|
dbca48892a | ||
|
|
6e760b921c | ||
|
|
1166b273f7 | ||
|
|
4987cc3273 | ||
|
|
57835186f5 | ||
|
|
20f9cfd313 | ||
|
|
a930c0d398 | ||
|
|
e3b39fad1d | ||
|
|
9ec5981226 | ||
|
|
b2ba6b2296 | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 |
126
.github/workflows/ci.yml
vendored
126
.github/workflows/ci.yml
vendored
@@ -82,56 +82,6 @@ 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
|
||||
@@ -192,28 +142,6 @@ 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
|
||||
@@ -288,20 +216,16 @@ 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-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
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage-standard.txt,coverage-extended.txt
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
|
||||
threshold=40
|
||||
echo "Coverage: ${total}% (threshold: ${threshold}%)"
|
||||
if (( $(echo "$total < $threshold" | bc -l) )); then
|
||||
@@ -311,31 +235,21 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
deadcode:
|
||||
needs: fast-gate
|
||||
@@ -606,7 +520,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, extended-integration, extended-platform-security]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -628,8 +542,6 @@ 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
|
||||
@@ -653,9 +565,7 @@ jobs:
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
"${{ needs.e2e-live.result }}" \
|
||||
"${{ needs.security.result }}" \
|
||||
"${{ needs.license-header.result }}" \
|
||||
"${{ needs.extended-integration.result }}" \
|
||||
"${{ needs.extended-platform-security.result }}"; do
|
||||
"${{ needs.license-header.result }}"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -46,8 +46,6 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -70,7 +68,7 @@ jobs:
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Build and upload draft release with GoReleaser
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
@@ -82,41 +80,14 @@ 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 \
|
||||
dist/install-extended.sh dist/install-extended.ps1 \
|
||||
npm-publish-asset/
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -126,76 +97,6 @@ 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,8 +5,7 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
- id: standard
|
||||
binary: lark-cli
|
||||
- binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
@@ -19,54 +18,12 @@ 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:
|
||||
- id: standard
|
||||
ids:
|
||||
- standard
|
||||
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
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
|
||||
format: zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
@@ -74,18 +31,6 @@ 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
|
||||
|
||||
9
Makefile
9
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 extended-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
|
||||
|
||||
all: test
|
||||
|
||||
@@ -50,7 +50,6 @@ fmt-check:
|
||||
script-test:
|
||||
bash scripts/resolve-changed-from.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,12 +121,6 @@ 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.
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -31,23 +30,21 @@ 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
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
// 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")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
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)
|
||||
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))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -530,7 +530,10 @@ 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 {
|
||||
@@ -555,19 +558,3 @@ 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,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -13,7 +12,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRunContext(cmd.Context(), opts)
|
||||
return authCheckRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,10 +46,6 @@ 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)
|
||||
@@ -63,74 +57,18 @@ func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if config.UserOpenId == "" {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
|
||||
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)
|
||||
missing := larkauth.MissingScopes(stored.Scope, required)
|
||||
missingSet := make(map[string]bool, len(missing))
|
||||
for _, s := range missing {
|
||||
missingSet[s] = true
|
||||
@@ -144,8 +82,8 @@ func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 && suggestion != "" {
|
||||
result["suggestion"] = suggestion
|
||||
if len(missing) > 0 {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !ok {
|
||||
|
||||
@@ -4,20 +4,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -152,128 +146,6 @@ 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
|
||||
|
||||
@@ -56,7 +56,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ 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 {
|
||||
@@ -68,9 +64,7 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
if !applyEditionStatus(result, diagnostics, editionStatus) {
|
||||
addStatusNote(result, diagnostics)
|
||||
}
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,6 @@ 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) {
|
||||
|
||||
40
cmd/build.go
40
cmd/build.go
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
@@ -29,7 +28,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -46,7 +44,6 @@ type buildConfig struct {
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
runtime *runtimebootstrap.Result
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
@@ -60,14 +57,6 @@ func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -153,9 +142,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
@@ -178,29 +167,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
startup := cfg.runtime
|
||||
if startup == nil {
|
||||
startup = runtimebootstrap.Resolve(inv.Profile)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
@@ -246,7 +219,6 @@ 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 {
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package config
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,38 +19,22 @@ 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
|
||||
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
||||
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
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)
|
||||
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))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -453,16 +452,10 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
||||
type stubConfigExtProvider struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
type stubConfigExtProvider struct{ name string }
|
||||
|
||||
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) {
|
||||
@@ -488,6 +481,7 @@ 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"}},
|
||||
}
|
||||
@@ -515,63 +509,6 @@ 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.
|
||||
|
||||
@@ -27,6 +27,13 @@ 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,6 +16,12 @@ 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,16 +132,19 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(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) {
|
||||
// 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) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
root := NewCmdConfig(f)
|
||||
leaf, _, err := root.Find([]string{"policy", "show"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
|
||||
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ 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 := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
|
||||
@@ -42,16 +42,6 @@ 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 := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,6 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
if handled, editionErr := runEditionDoctor(opts, checks); handled {
|
||||
return editionErr
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -134,7 +130,8 @@ 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.
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
@@ -218,7 +215,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 := fetchLatestForEdition()
|
||||
latest, err := update.FetchLatest()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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,6 +4,7 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@@ -174,44 +175,6 @@ 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.
|
||||
@@ -232,8 +195,12 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
f, out, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
f.Credential = cred
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// 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())
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/adapter/lark/websocket"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
|
||||
var domain string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -44,7 +46,13 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
|
||||
ingress := &websocket.FeishuSource{
|
||||
AppID: cfg.AppID,
|
||||
AppSecret: cfg.AppSecret,
|
||||
Domain: domain,
|
||||
Logger: logger,
|
||||
}
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd := NewCmdBus(f, compileCatalog())
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/cmd/event/render"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -23,8 +24,10 @@ import (
|
||||
"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/adapter/localbus/transport"
|
||||
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -37,9 +40,10 @@ type consumeCmdOpts struct {
|
||||
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
|
||||
func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
|
||||
var o consumeCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -57,7 +61,7 @@ Use 'event list' to see all available EventKeys.
|
||||
Use 'event schema <EventKey>' for parameter details.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConsume(cmd, f, args[0], o)
|
||||
return runConsume(cmd, f, snap, args[0], o)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -76,7 +81,7 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, eventKey string, o consumeCmdOpts) error {
|
||||
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
|
||||
ignoreBrokenPipe()
|
||||
|
||||
@@ -90,10 +95,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
return err
|
||||
}
|
||||
|
||||
keyDef, ok := eventlib.Lookup(eventKey)
|
||||
entry, ok := snap.Resolve(eventKey)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(eventKey)
|
||||
return unknownEventKeyErr(snap, eventKey)
|
||||
}
|
||||
keyDef := entry.Definition()
|
||||
|
||||
identity, err := resolveIdentity(cmd, f, keyDef)
|
||||
if err != nil {
|
||||
@@ -120,9 +126,16 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
// Surface auth errors before forking the bus daemon. A dry run instead
|
||||
// reports the unusable credential as a blocked precondition: the caller
|
||||
// asked what would happen, and "a real run would refuse to authenticate"
|
||||
// is a legitimate part of that answer.
|
||||
var tokenErr error
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
return err
|
||||
if !o.dryRun {
|
||||
return err
|
||||
}
|
||||
tokenErr = err
|
||||
}
|
||||
|
||||
apiClient, err := f.NewAPIClient()
|
||||
@@ -169,11 +182,31 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
|
||||
svc := &appconsume.Service{
|
||||
Strategies: consumeStrategies,
|
||||
Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }),
|
||||
Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) {
|
||||
return readPreconditions(ctx, pf, appVerErr, tokenErr), nil
|
||||
}),
|
||||
}
|
||||
req := appconsume.Request{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
OutputDir: outputDir,
|
||||
DryRun: o.dryRun,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}
|
||||
decision, err := svc.Decide(cmd.Context(), entry, req, appconsume.ExecutionContext{API: runtime})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopes(cmd.Context(), pf); err != nil {
|
||||
return err
|
||||
|
||||
if o.dryRun {
|
||||
return render.WriteDecisionJSON(f.IOStreams.Out, f.IOStreams.ErrOut, string(identity), decision.View())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
@@ -204,23 +237,26 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error {
|
||||
return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Def: keyDef,
|
||||
Params: decision.NormalizedParams(),
|
||||
ParamsNormalized: true,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
Prepare: prepare,
|
||||
})
|
||||
})
|
||||
return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime})
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
@@ -248,10 +284,14 @@ type preflightCtx struct {
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
// preflightScopes compares required scopes against session-available scopes
|
||||
// (user: UAT stored; bot: appVer.TenantScopes). checked reports whether a
|
||||
// comparison actually happened: "the ledger was unavailable" and "the check
|
||||
// passed" are different answers, and only the caller can decide how loudly to
|
||||
// say the first one.
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err error) {
|
||||
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -261,24 +301,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
switch {
|
||||
case pf.identity.IsBot():
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
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
|
||||
return false, nil //nolint:nilerr // best-effort: the bus handshake surfaces the real auth error
|
||||
}
|
||||
storedScopes = result.Scopes
|
||||
default:
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
return true, errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
|
||||
75
cmd/event/consume_dryrun_test.go
Normal file
75
cmd/event/consume_dryrun_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// A dry run in a degraded environment (the test factory has no reachable
|
||||
// platform, so every weak read-only check comes back unanswered) still exits
|
||||
// zero with a structured decision that honestly says "unknown" — and performs
|
||||
// none of its declared write effects.
|
||||
func TestDryRun_DegradedEnvironmentStaysHonestAndSideEffectFree(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_test"})
|
||||
snap := compileCatalog()
|
||||
|
||||
tmp := t.TempDir()
|
||||
prevWD, _ := os.Getwd()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(prevWD) })
|
||||
|
||||
cmd := NewCmdConsume(f, snap)
|
||||
cmd.SetArgs([]string{"im.message.receive_v1", "--as", "bot", "--dry-run", "--output-dir", "events-out"})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run must not fail on unusable credentials, got: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Data struct {
|
||||
Decision struct {
|
||||
Status string `json:"status"`
|
||||
Preconditions []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"preconditions"`
|
||||
WouldWrite []string `json:"would_write"`
|
||||
} `json:"decision"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !envelope.OK || !envelope.DryRun {
|
||||
t.Errorf("want ok=true dry_run=true, got: %s", stdout.String())
|
||||
}
|
||||
if envelope.Data.Decision.Status != "unknown" {
|
||||
t.Errorf("unanswerable weak checks must render unknown, not fake readiness; got status %q", envelope.Data.Decision.Status)
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range envelope.Data.Decision.Preconditions {
|
||||
names[p.Name] = p.Status
|
||||
}
|
||||
if names["credentials_available"] == "" || names["console_event_published"] == "" || names["scopes_granted"] == "" {
|
||||
t.Errorf("preconditions must name every check, got: %v", names)
|
||||
}
|
||||
|
||||
// The declared write side effects must stay declarations: the requested
|
||||
// output dir must not exist after a dry run.
|
||||
if _, err := os.Stat(filepath.Join(tmp, "events-out")); !os.IsNotExist(err) {
|
||||
t.Error("dry-run created the output directory; the preview performed a side effect")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -17,31 +16,15 @@ 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)
|
||||
|
||||
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)
|
||||
snap := compileCatalog()
|
||||
cmd.AddCommand(NewCmdConsume(f, snap))
|
||||
cmd.AddCommand(NewCmdList(f, snap))
|
||||
cmd.AddCommand(NewCmdSchema(f, snap))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f, snap))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -288,9 +288,10 @@ func errorAs(err error, target interface{}) bool {
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
snap := compileCatalog()
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
cmd := NewCmdConsume(f, snap)
|
||||
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("consume missing --%s flag", flag)
|
||||
@@ -320,14 +321,22 @@ func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
cmd := NewCmdList(f)
|
||||
cmd := NewCmdList(f, snap)
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
t.Error("list missing --json flag")
|
||||
}
|
||||
domainFlag := cmd.Flags().Lookup("domain")
|
||||
if domainFlag == nil {
|
||||
t.Fatal("list missing --domain flag")
|
||||
}
|
||||
wantUsage := "Only list EventKeys of this domain. Valid domains: " + strings.Join(snap.Domains(), ", ")
|
||||
if domainFlag.Usage != wantUsage {
|
||||
t.Errorf("--domain usage = %q, want %q", domainFlag.Usage, wantUsage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
cmd := NewCmdBus(f)
|
||||
cmd := NewCmdBus(f, snap)
|
||||
if !cmd.Hidden {
|
||||
t.Error("bus should be hidden (internal daemon entrypoint)")
|
||||
}
|
||||
|
||||
81
cmd/event/golden_test.go
Normal file
81
cmd/event/golden_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing")
|
||||
|
||||
// goldenSchemaKeys picks one key per rendering path so every branch of the
|
||||
// list/schema output stays pinned: a processed key with a flat custom schema,
|
||||
// a native key with field overrides, a callback key with a single consumer,
|
||||
// and a key with a required parameter plus a pre-consume hook.
|
||||
var goldenSchemaKeys = map[string]string{
|
||||
"schema_im_message_receive": "im.message.receive_v1",
|
||||
"schema_im_chat_updated": "im.chat.updated_v1",
|
||||
"schema_card_action_trigger": "card.action.trigger",
|
||||
"schema_board_whiteboard": "board.whiteboard.updated_v1",
|
||||
}
|
||||
|
||||
// The golden files pin stdout byte-for-byte. The output is deterministic:
|
||||
// the snapshot keeps keys sorted, encoding/json sorts object keys, and nothing
|
||||
// on the rendering path reads the clock or randomness. Regenerate with:
|
||||
//
|
||||
// go test ./cmd/event/ -run TestGolden -update
|
||||
func TestGolden_ListOutput(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runList(f, snap, "", asJSON); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
assertGolden(t, name, stdout.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGolden_SchemaOutput(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
for name, key := range goldenSchemaKeys {
|
||||
for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} {
|
||||
t.Run(name+suffix, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, snap, key, asJSON); err != nil {
|
||||
t.Fatalf("runSchema(%s): %v", key, err)
|
||||
}
|
||||
assertGolden(t, name+suffix, stdout.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertGolden(t *testing.T, name, got string) {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "golden", name+".golden")
|
||||
if *updateGolden {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("missing golden %s (regenerate with -update): %v", name, err)
|
||||
}
|
||||
if string(want) != got {
|
||||
t.Errorf("output drifted from golden %s\n--- want\n%s\n--- got\n%s", name, want, got)
|
||||
}
|
||||
}
|
||||
@@ -10,31 +10,44 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
|
||||
var asJSON bool
|
||||
var domain string
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available EventKeys",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --domain to keep one domain only, --json for machine-readable output.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(f, asJSON)
|
||||
return runList(f, snap, domain, asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmd.Flags().StringVar(&domain, "domain", "", fmt.Sprintf(
|
||||
"Only list EventKeys of this domain. Valid domains: %s",
|
||||
strings.Join(snap.Domains(), ", "),
|
||||
))
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
all := eventlib.ListAll()
|
||||
|
||||
func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON bool) error {
|
||||
entries, err := entriesForDomain(snap, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return writeListJSON(f, all)
|
||||
return writeListJSON(f, entries)
|
||||
}
|
||||
all := make([]*eventlib.KeyDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
all = append(all, entry.Definition())
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
@@ -104,18 +117,43 @@ func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
|
||||
type row struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
// listRow is the JSON shape of one `event list --json` row. It is a named
|
||||
// type (not a function-local literal) so the render contract test can walk
|
||||
// its fields and reject accidental additions to the public output.
|
||||
type listRow struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
}
|
||||
|
||||
// entriesForDomain filters at the snapshot query layer: without a domain the
|
||||
// full catalog comes back untouched; with one, rows are only removed, never
|
||||
// reshaped. An unknown domain is rejected with the valid set spelled out.
|
||||
func entriesForDomain(snap *catalog.Snapshot, domain string) ([]*catalog.Entry, error) {
|
||||
if domain == "" {
|
||||
return snap.Entries(), nil
|
||||
}
|
||||
rows := make([]row, len(all))
|
||||
for i, def := range all {
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
var filtered []*catalog.Entry
|
||||
for _, entry := range snap.Entries() {
|
||||
if entry.Descriptor().Domain == domain {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown domain: %s", domain).
|
||||
WithParam("--domain").
|
||||
WithHint("valid domains: %s", strings.Join(snap.Domains(), ", "))
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, entries []*catalog.Entry) error {
|
||||
rows := make([]listRow, len(entries))
|
||||
for i, entry := range entries {
|
||||
rows[i] = listRow{
|
||||
KeyDefinition: entry.Definition(),
|
||||
ResolvedSchema: entry.Output().SchemaJSON,
|
||||
}
|
||||
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, rows)
|
||||
return nil
|
||||
|
||||
96
cmd/event/list_domain_test.go
Normal file
96
cmd/event/list_domain_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Filtering only removes rows: the vc selection must be exactly the catalog's
|
||||
// vc keys, and every remaining row keeps the unfiltered field set.
|
||||
func TestListDomain_FilterKeepsExactlyTheRequestedDomain(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runList(f, snap, "vc", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := map[string]bool{}
|
||||
for _, key := range snap.Keys() {
|
||||
if strings.HasPrefix(key, "vc.") {
|
||||
want[key] = true
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
t.Fatal("the catalog has no vc keys; the filter test proves nothing")
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
var key string
|
||||
_ = json.Unmarshal(row["key"], &key)
|
||||
got[key] = true
|
||||
for _, field := range []string{"event_type", "schema", "resolved_output_schema"} {
|
||||
if _, ok := row[field]; !ok {
|
||||
t.Errorf("%s: filtering must not reshape rows; %q is missing", key, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("filtered rows = %v, want the exact vc set %v", got, want)
|
||||
}
|
||||
for key := range want {
|
||||
if !got[key] {
|
||||
t.Errorf("vc key missing from the filtered list: %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDomain_TextFilter(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runList(f, snap, "im", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "im.message.receive_v1") {
|
||||
t.Error("im keys must be listed")
|
||||
}
|
||||
for _, foreign := range []string{"vc.", "minutes.", "board.", "approval."} {
|
||||
if strings.Contains(out, foreign) {
|
||||
t.Errorf("foreign domain %q leaked into the filtered text output", foreign)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDomain_UnknownDomainIsRejectedWithTheValidSet(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
err := runList(f, snap, "definitely-bogus", true)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown domain must be rejected")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("want invalid_argument, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown domain: definitely-bogus") {
|
||||
t.Errorf("error must name the rejected value, got %v", err)
|
||||
}
|
||||
for _, domain := range []string{"application", "approval", "board", "card", "im", "minutes", "task", "vc"} {
|
||||
if !strings.Contains(problem.Hint, domain) {
|
||||
t.Errorf("hint must list valid domain %q, got %q", domain, problem.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,18 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
if _, ok := snap.Resolve(key); !ok {
|
||||
t.Fatalf("snap.Resolve(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
if err := runList(f, compileCatalog(), "", false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
if err := runList(f, compileCatalog(), "", true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
}
|
||||
|
||||
|
||||
65
cmd/event/preconditions_test.go
Normal file
65
cmd/event/preconditions_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
||||
)
|
||||
|
||||
func preconditionByName(list []appconsume.Precondition, name string) *appconsume.Precondition {
|
||||
for i := range list {
|
||||
if list[i].Name == name {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// An unusable credential blocks the decision and carries the exact error a
|
||||
// real run would have returned, so both paths refuse for the same reason.
|
||||
func TestReadPreconditions_TokenErrorBlocksWithTheSameError(t *testing.T) {
|
||||
tokenErr := errors.New("no tenant token available")
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_test",
|
||||
identity: core.AsBot,
|
||||
keyDef: &eventlib.KeyDefinition{Key: "demo.thing.updated_v1"},
|
||||
}
|
||||
got := readPreconditions(context.Background(), pf, nil, tokenErr)
|
||||
|
||||
cred := preconditionByName(got, "credentials_available")
|
||||
if cred == nil {
|
||||
t.Fatal("credentials_available precondition missing")
|
||||
}
|
||||
if cred.Status != appconsume.PreconditionBlocked || !errors.Is(cred.BlockErr, tokenErr) {
|
||||
t.Errorf("token failure must block with the original error, got %+v", cred)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope ledger nobody could read is reported as unknown — never as ok.
|
||||
func TestReadPreconditions_UnreadableScopesAreUnknown(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_test",
|
||||
identity: core.AsBot,
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "demo.thing.updated_v1",
|
||||
Scopes: []string{"demo:read"},
|
||||
},
|
||||
appVer: nil, // no published version: the bot scope ledger is unreadable
|
||||
}
|
||||
got := readPreconditions(context.Background(), pf, nil, nil)
|
||||
|
||||
scopes := preconditionByName(got, "scopes_granted")
|
||||
if scopes == nil {
|
||||
t.Fatal("scopes_granted precondition missing")
|
||||
}
|
||||
if scopes.Status != appconsume.PreconditionUnknown {
|
||||
t.Errorf("an unreadable ledger must report unknown, got %q", scopes.Status)
|
||||
}
|
||||
}
|
||||
@@ -108,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", core.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)
|
||||
}
|
||||
@@ -124,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", core.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)
|
||||
}
|
||||
@@ -136,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", core.AsBot, def, appVer))
|
||||
_, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
@@ -169,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", core.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)
|
||||
}
|
||||
}
|
||||
|
||||
96
cmd/event/render/decision.go
Normal file
96
cmd/event/render/decision.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package render turns consume decisions into user-facing output. It is the
|
||||
// only place a decision becomes JSON; the application layer never formats
|
||||
// anything itself.
|
||||
package render
|
||||
|
||||
import (
|
||||
"io"
|
||||
"regexp"
|
||||
|
||||
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sensitiveParamName matches parameter names whose values must never be
|
||||
// echoed back in a rendered decision. Names are matched, not values: a
|
||||
// credential-bearing parameter is identifiable by its declaration, and
|
||||
// guessing at value shapes would miss more than it catches.
|
||||
var sensitiveParamName = regexp.MustCompile(`(?i)(token|secret|password|credential|cookie)`)
|
||||
|
||||
func redactParams(params map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(params))
|
||||
for name, value := range params {
|
||||
if sensitiveParamName.MatchString(name) {
|
||||
out[name] = "[redacted]"
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decisionPayload is the JSON shape under data.decision — snake_case, stable,
|
||||
// documented in the event skill. Field additions must be additive.
|
||||
type decisionPayload struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Domain string `json:"domain"`
|
||||
Identity string `json:"identity"`
|
||||
Status string `json:"status"`
|
||||
Params map[string]string `json:"params"`
|
||||
Scope string `json:"scope"`
|
||||
Preconditions []preconditionView `json:"preconditions"`
|
||||
Preparation *preparationView `json:"preparation,omitempty"`
|
||||
WouldRead []string `json:"would_read"`
|
||||
WouldWrite []string `json:"would_write"`
|
||||
}
|
||||
|
||||
type preconditionView struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type preparationView struct {
|
||||
Strategy string `json:"strategy"`
|
||||
Condition string `json:"condition"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// WriteDecisionJSON emits the decision inside the standard success envelope
|
||||
// with the envelope's own top-level dry_run marker set.
|
||||
func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.DecisionView) error {
|
||||
return output.WriteSuccessEnvelope(map[string]any{
|
||||
"decision": toPayload(v),
|
||||
}, output.SuccessEnvelopeOptions{
|
||||
CommandPath: "event consume",
|
||||
Identity: identity,
|
||||
DryRun: true,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
func toPayload(v appconsume.DecisionView) decisionPayload {
|
||||
p := decisionPayload{
|
||||
EventKey: v.EventKey,
|
||||
Domain: v.Domain,
|
||||
Identity: v.Identity,
|
||||
Status: v.Status,
|
||||
Params: redactParams(v.Params),
|
||||
Scope: v.Scope,
|
||||
WouldRead: v.WouldRead,
|
||||
WouldWrite: v.WouldWrite,
|
||||
}
|
||||
p.Preconditions = make([]preconditionView, 0, len(v.Preconditions))
|
||||
for _, pc := range v.Preconditions {
|
||||
p.Preconditions = append(p.Preconditions, preconditionView(pc))
|
||||
}
|
||||
if v.Preparation != nil {
|
||||
pv := preparationView(*v.Preparation)
|
||||
p.Preparation = &pv
|
||||
}
|
||||
return p
|
||||
}
|
||||
114
cmd/event/render/decision_test.go
Normal file
114
cmd/event/render/decision_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
||||
)
|
||||
|
||||
func sampleView() appconsume.DecisionView {
|
||||
return appconsume.DecisionView{
|
||||
EventKey: "vc.note.generated_v1",
|
||||
Domain: "vc",
|
||||
Identity: "user",
|
||||
Status: "ready",
|
||||
Params: map[string]string{"whiteboard_id": "wb-1", "access_token": "sk-SENSITIVE-VALUE"},
|
||||
Scope: "vc.note.generated_v1",
|
||||
Preconditions: []appconsume.PreconditionView{
|
||||
{Name: "console_event_published", Status: "ok"},
|
||||
{Name: "scopes_granted", Status: "ok"},
|
||||
},
|
||||
Preparation: &appconsume.PreparationView{
|
||||
Strategy: "legacy_preconsume", Condition: "first_consumer_for_scope", Action: "register_event_delivery",
|
||||
},
|
||||
WouldRead: []string{"local_bus_probe", "app_metadata_preflight"},
|
||||
WouldWrite: []string{"start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"},
|
||||
}
|
||||
}
|
||||
|
||||
// The JSON contract: dry_run is the envelope's own top-level marker (never a
|
||||
// data field), and the decision sits under data.decision with its documented
|
||||
// members.
|
||||
func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
if err := WriteDecisionJSON(&out, &errOut, "user", sampleView()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if string(envelope["ok"]) != "true" || string(envelope["dry_run"]) != "true" {
|
||||
t.Errorf("envelope must carry top-level ok=true and dry_run=true, got %s", out.String())
|
||||
}
|
||||
if _, misplaced := envelope["decision"]; misplaced {
|
||||
t.Error("decision must live under data, not at the envelope top level")
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Decision struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Domain string `json:"domain"`
|
||||
Identity string `json:"identity"`
|
||||
Status string `json:"status"`
|
||||
Params map[string]string `json:"params"`
|
||||
Scope string `json:"scope"`
|
||||
Preparation *struct {
|
||||
Strategy string `json:"strategy"`
|
||||
Condition string `json:"condition"`
|
||||
Action string `json:"action"`
|
||||
} `json:"preparation"`
|
||||
WouldRead []string `json:"would_read"`
|
||||
WouldWrite []string `json:"would_write"`
|
||||
DryRun *bool `json:"dry_run"`
|
||||
} `json:"decision"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope["data"], &data); err != nil {
|
||||
t.Fatalf("data.decision does not match the documented shape: %v", err)
|
||||
}
|
||||
d := data.Decision
|
||||
if d.EventKey != "vc.note.generated_v1" || d.Domain != "vc" || d.Identity != "user" || d.Status != "ready" {
|
||||
t.Errorf("identity facts drifted: %+v", d)
|
||||
}
|
||||
if d.Preparation == nil || d.Preparation.Condition != "first_consumer_for_scope" {
|
||||
t.Errorf("conditional preparation must be stated: %+v", d.Preparation)
|
||||
}
|
||||
if len(d.WouldRead) == 0 || len(d.WouldWrite) == 0 {
|
||||
t.Error("would_read / would_write must be present")
|
||||
}
|
||||
if d.DryRun != nil {
|
||||
t.Error("dry_run inside data.decision would duplicate the envelope marker")
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive parameter values never reach the rendered output. The control
|
||||
// assertion first proves the sentinel would be visible if leaked.
|
||||
func TestWriteDecision_RedactsSensitiveParams(t *testing.T) {
|
||||
const sentinel = "sk-SENSITIVE-VALUE"
|
||||
view := sampleView()
|
||||
if !strings.Contains(view.Params["access_token"], sentinel) {
|
||||
t.Fatal("control failed: the sentinel is not in the input, the test cannot prove redaction")
|
||||
}
|
||||
|
||||
var jsonOut, jsonErr bytes.Buffer
|
||||
if err := WriteDecisionJSON(&jsonOut, &jsonErr, "user", view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(jsonOut.String(), sentinel) {
|
||||
t.Errorf("JSON output leaks a sensitive param value: %s", jsonOut.String())
|
||||
}
|
||||
compact := strings.ReplaceAll(strings.ReplaceAll(jsonOut.String(), "\n", ""), " ", "")
|
||||
if !strings.Contains(compact, `"access_token":"[redacted]"`) {
|
||||
t.Errorf("sensitive param must render as redacted, got: %s", jsonOut.String())
|
||||
}
|
||||
if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) {
|
||||
t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String())
|
||||
}
|
||||
}
|
||||
121
cmd/event/render/redaction_guard_test.go
Normal file
121
cmd/event/render/redaction_guard_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
)
|
||||
|
||||
// This file is a guard, not a contract: it does not pin what the redaction
|
||||
// regex matches, it hunts for declared parameter names that smell like
|
||||
// credentials yet would render verbatim. The detector wordlist is therefore
|
||||
// deliberately wider than the production sensitiveParamName pattern — a hit
|
||||
// here means either the parameter should be renamed or the production
|
||||
// pattern must grow, decided by a human, never by loosening this list.
|
||||
|
||||
// credentialWords are matched against whole '_'/'-'/'.'-separated segments of
|
||||
// a parameter name, so chat_key or tokenizer_mode cannot trip them. The bare
|
||||
// word "key" is intentionally absent (identifier names like whiteboard_id or
|
||||
// a hypothetical chat_key are not credentials); the api/key pairing is what
|
||||
// carries credential semantics and is detected as a pair below.
|
||||
var credentialWords = map[string]bool{
|
||||
"token": true,
|
||||
"secret": true,
|
||||
"password": true,
|
||||
"credential": true,
|
||||
"credentials": true,
|
||||
"cookie": true,
|
||||
"auth": true,
|
||||
"signature": true,
|
||||
"bearer": true,
|
||||
"apikey": true,
|
||||
}
|
||||
|
||||
// smellsLikeCredential reports whether a parameter name carries credential
|
||||
// semantics per the guard wordlist: any single segment in credentialWords,
|
||||
// or the adjacent segment pair api+key.
|
||||
func smellsLikeCredential(name string) bool {
|
||||
segments := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
|
||||
return r == '_' || r == '-' || r == '.'
|
||||
})
|
||||
for i, seg := range segments {
|
||||
if credentialWords[seg] {
|
||||
return true
|
||||
}
|
||||
if seg == "api" && i+1 < len(segments) && segments[i+1] == "key" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unredactedCredentialParams returns the names that smell like credentials
|
||||
// but are NOT matched by the production redaction pattern — every such name
|
||||
// would render its value verbatim in a dry-run decision.
|
||||
func unredactedCredentialParams(names []string) []string {
|
||||
var findings []string
|
||||
for _, name := range names {
|
||||
if smellsLikeCredential(name) && !sensitiveParamName.MatchString(name) {
|
||||
findings = append(findings, name)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// The detector itself must bite before the live scan means anything: known
|
||||
// credential-shaped names that the production pattern misses must be caught,
|
||||
// and ordinary identifier names must pass.
|
||||
func TestRedactionGuardDetector_SelfCheck(t *testing.T) {
|
||||
// Credential-shaped and covered by the production pattern: no finding.
|
||||
for _, name := range []string{"access_token", "client_secret", "user_password", "session_cookie", "sso_credential"} {
|
||||
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
|
||||
t.Errorf("%q is redacted by the production pattern, the guard must not flag it, got %v", name, got)
|
||||
}
|
||||
}
|
||||
// Credential-shaped but NOT covered by the production pattern today: the
|
||||
// guard must flag these, otherwise it can never catch a real gap.
|
||||
for _, name := range []string{"api_key", "auth_code", "request_signature", "bearer_value"} {
|
||||
if got := unredactedCredentialParams([]string{name}); len(got) != 1 {
|
||||
t.Errorf("%q smells like a credential and is not redacted; the guard must flag it, got %v", name, got)
|
||||
}
|
||||
}
|
||||
// Ordinary identifiers, including the wide-false-positive shapes the
|
||||
// wordlist is segment-matched to avoid: no finding.
|
||||
for _, name := range []string{"whiteboard_id", "chat_key", "tokenizer_mode", "author", "meeting_no"} {
|
||||
if got := unredactedCredentialParams([]string{name}); len(got) != 0 {
|
||||
t.Errorf("%q is an ordinary identifier, the guard must not flag it, got %v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every declared parameter of every compiled EventKey either carries no
|
||||
// credential semantics or is caught by the production redaction pattern.
|
||||
func TestRedactionGuard_CatalogParamsHaveNoUnredactedCredentials(t *testing.T) {
|
||||
snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{
|
||||
catalog.StrategyNone,
|
||||
catalog.StrategyLegacyPreConsume,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compile catalog: %v", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, entry := range snap.Entries() {
|
||||
desc := entry.Descriptor()
|
||||
for _, p := range desc.Params {
|
||||
names = append(names, desc.Key+": "+p.Name)
|
||||
if findings := unredactedCredentialParams([]string{p.Name}); len(findings) != 0 {
|
||||
t.Errorf("EventKey %s declares param %q which smells like a credential but is not matched by the redaction pattern; rename the param or extend sensitiveParamName deliberately", desc.Key, p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A scan that visited no parameters proves nothing.
|
||||
if len(names) == 0 {
|
||||
t.Fatal("the compiled catalog declares no parameters at all; the guard scanned nothing")
|
||||
}
|
||||
}
|
||||
163
cmd/event/render_contract_test.go
Normal file
163
cmd/event/render_contract_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// renderedDeclarationFields lists every JSON field the list/schema commands
|
||||
// are allowed to expose, each with the reason it belongs to the public
|
||||
// contract. Golden files pin today's bytes; this gate protects tomorrow: a
|
||||
// field added to the rendered structs (promoted through the embedded
|
||||
// definition or nested anywhere under it) must either appear here
|
||||
// deliberately or be tagged `json:"-"`. The set is flat — an entry admits its
|
||||
// rendered name at any nesting level, which is the same latitude
|
||||
// encoding/json gives a name.
|
||||
var renderedDeclarationFields = map[string]string{
|
||||
"key": "stable identifier agents subscribe by",
|
||||
"domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical",
|
||||
"display_name": "human-readable name for pickers",
|
||||
"description": "what the event means (KeyDefinition) / what the parameter does (ParamDef)",
|
||||
"event_type": "upstream event type behind this key",
|
||||
"subscription_type": "which console ledger the precheck reads",
|
||||
"params": "declared consume parameters",
|
||||
"schema": "declared schema source (native/custom markers)",
|
||||
"scopes": "OAuth scopes required to consume",
|
||||
"auth_types": "identities the key accepts",
|
||||
"required_console_events": "console switches that must be enabled",
|
||||
"buffer_size": "delivery buffer size after normalization",
|
||||
"workers": "worker count after normalization",
|
||||
"single_consumer": "whether a second consumer is rejected",
|
||||
"resolved_output_schema": "fully resolved JSON schema of stdout events",
|
||||
"jq_root_path": "schema command only: jq root for consuming stdout",
|
||||
|
||||
// Nested under params (ParamDef): everything an agent needs to pass the
|
||||
// parameter correctly.
|
||||
"name": "parameter name as passed via --param",
|
||||
"type": "parameter value type (string/enum/multi/bool/int)",
|
||||
"required": "whether the parameter must be provided",
|
||||
"default": "value applied when the parameter is omitted",
|
||||
"values": "allowed values for enum/multi parameters",
|
||||
"subscription_key": "whether the parameter is part of the subscription identity",
|
||||
|
||||
// Nested under params.values (ParamValue).
|
||||
"value": "one allowed parameter value",
|
||||
"desc": "what choosing this value means",
|
||||
|
||||
// Nested under schema (SchemaDef / SchemaSpec): declaration markers only;
|
||||
// the resolved schema is the sibling resolved_output_schema.
|
||||
"native": "marker for keys delivering the raw V2 envelope",
|
||||
"custom": "marker for keys delivering processed output",
|
||||
"field_overrides": "per-field annotations overriding the reflected schema",
|
||||
"raw": "raw declared schema bytes; empty for reflected types",
|
||||
|
||||
// Nested under schema.field_overrides (schemas.FieldMeta). The type has
|
||||
// no json tags, so encoding/json renders the Go field names — pinned
|
||||
// as-is because retagging them would change the public bytes.
|
||||
"Description": "override for the field's schema description",
|
||||
"Enum": "override for the field's allowed values",
|
||||
"Kind": "override rendered as the field's schema format",
|
||||
}
|
||||
|
||||
// TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes,
|
||||
// following embedded struct promotion and recursing into every named type
|
||||
// reachable through the rendered fields, and fails on any exported member
|
||||
// that is neither allowlisted nor explicitly excluded from JSON.
|
||||
func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) {
|
||||
emitted := map[string]bool{}
|
||||
for _, typ := range []reflect.Type{
|
||||
reflect.TypeFor[listRow](),
|
||||
reflect.TypeFor[schemaPayload](),
|
||||
} {
|
||||
walkRenderedFields(t, typ, emitted, map[reflect.Type]bool{})
|
||||
}
|
||||
|
||||
if len(emitted) == 0 {
|
||||
t.Fatal("no rendered fields were visited; the gate scanned nothing")
|
||||
}
|
||||
// The embedded definition is where leaks would hide: prove promotion was
|
||||
// actually followed by requiring fields that only exist on it. The nested
|
||||
// sentinels prove each recursion path is really taken: subscription_key
|
||||
// (slice-of-struct: ParamDef), desc (slice inside a nested struct:
|
||||
// ParamValue), raw (pointer-to-struct: SchemaSpec), Enum (map value:
|
||||
// FieldMeta, rendered under its Go name because the type is untagged).
|
||||
for _, sentinel := range []string{
|
||||
"key", "event_type", "resolved_output_schema",
|
||||
"subscription_key", "desc", "raw", "Enum",
|
||||
} {
|
||||
if !emitted[sentinel] {
|
||||
t.Fatalf("field %q was not visited; the walker no longer reaches every rendered shape", sentinel)
|
||||
}
|
||||
}
|
||||
for name := range renderedDeclarationFields {
|
||||
if !emitted[name] {
|
||||
t.Errorf("allowlist entry %q is stale: no rendered struct emits it", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// walkRenderedFields records every JSON field name typ can render: embedded
|
||||
// structs promote into the parent object, and any struct reachable through a
|
||||
// field's type — behind pointers, slice/array elements, or map values — is
|
||||
// walked in turn, so a field added to a nested type like ParamDef cannot
|
||||
// escape the gate. visited breaks cycles; a type already recorded in this
|
||||
// walk contributes nothing new.
|
||||
func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool, visited map[reflect.Type]bool) {
|
||||
t.Helper()
|
||||
typ = nestedStructType(typ)
|
||||
if typ == nil || visited[typ] {
|
||||
return
|
||||
}
|
||||
visited[typ] = true
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
tag := field.Tag.Get("json")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
if field.Anonymous && tag == "" {
|
||||
if ft := nestedStructType(field.Type); ft != nil {
|
||||
// Embedded struct without a tag: fields promote into the
|
||||
// parent JSON object.
|
||||
walkRenderedFields(t, ft, emitted, visited)
|
||||
continue
|
||||
}
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name == "" {
|
||||
// encoding/json renders an untagged exported field under its Go
|
||||
// name (schemas.FieldMeta does this today); the rendered name is
|
||||
// what the contract governs, so it is what must be declared.
|
||||
name = field.Name
|
||||
}
|
||||
if _, ok := renderedDeclarationFields[name]; !ok {
|
||||
t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it with json:\"-\"", typ.Name(), field.Name, name)
|
||||
}
|
||||
emitted[name] = true
|
||||
walkRenderedFields(t, field.Type, emitted, visited)
|
||||
}
|
||||
}
|
||||
|
||||
// nestedStructType unwraps pointers, slice/array elements, and map values
|
||||
// until it reaches the struct that would render as a JSON object; nil means
|
||||
// the type renders as a leaf (scalar, string, raw bytes) and holds no fields
|
||||
// to govern.
|
||||
func nestedStructType(typ reflect.Type) reflect.Type {
|
||||
for {
|
||||
switch typ.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
|
||||
typ = typ.Elem()
|
||||
case reflect.Struct:
|
||||
return typ
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,75 +11,13 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
|
||||
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
base, err := renderSpec(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if base == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if isNative {
|
||||
base = schemas.WrapV2Envelope(base)
|
||||
}
|
||||
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
|
||||
return base, nil, nil
|
||||
}
|
||||
|
||||
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
|
||||
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
|
||||
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type), nil
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
buf := make(json.RawMessage, len(s.Raw))
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <EventKey>",
|
||||
@@ -87,7 +25,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSchema(f, args[0], asJSON)
|
||||
return runSchema(f, snap, args[0], asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
@@ -95,14 +33,15 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
def, ok := eventlib.Lookup(key)
|
||||
func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bool) error {
|
||||
entry, ok := snap.Resolve(key)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(key)
|
||||
return unknownEventKeyErr(snap, key)
|
||||
}
|
||||
def := entry.Definition()
|
||||
|
||||
if asJSON {
|
||||
return writeSchemaJSON(f, def)
|
||||
return writeSchemaJSON(f, entry)
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
@@ -170,10 +109,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved := entry.Output().SchemaJSON
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
printIndentedJSON(out, resolved)
|
||||
@@ -202,30 +138,22 @@ func printIndentedJSON(out io.Writer, raw json.RawMessage) {
|
||||
fmt.Fprintf(out, " %s\n", string(formatted))
|
||||
}
|
||||
|
||||
// schemaPayload is the JSON shape of `event schema --json`. It is a named
|
||||
// type (not a function-local literal) so the render contract test can walk
|
||||
// its fields and reject accidental additions to the public output.
|
||||
type schemaPayload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
|
||||
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
|
||||
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
|
||||
type payload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jqRootPath string
|
||||
if resolved != nil {
|
||||
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
|
||||
_, isNative := pickSpec(def.Schema)
|
||||
jqRootPath = "."
|
||||
if isNative {
|
||||
jqRootPath = ".event"
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, payload{
|
||||
KeyDefinition: def,
|
||||
ResolvedSchema: resolved,
|
||||
JQRootPath: jqRootPath,
|
||||
func writeSchemaJSON(f *cmdutil.Factory, entry *catalog.Entry) error {
|
||||
contract := entry.Output()
|
||||
output.PrintJson(f.IOStreams.Out, schemaPayload{
|
||||
KeyDefinition: entry.Definition(),
|
||||
ResolvedSchema: contract.SchemaJSON,
|
||||
JQRootPath: contract.JQRootPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,15 +10,27 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
// compileTestSnapshot compiles synthetic declarations into a snapshot using
|
||||
// the same strategy set the production wiring provides.
|
||||
func compileTestSnapshot(t *testing.T, defs ...eventlib.KeyDefinition) *catalog.Snapshot {
|
||||
t.Helper()
|
||||
snap, err := catalog.Compile(defs, catalog.StrategyRefs{
|
||||
catalog.StrategyNone,
|
||||
catalog.StrategyLegacyPreConsume,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compile test catalog: %v", err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
@@ -45,7 +57,7 @@ type approvalSchemaJSONProperty struct {
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
@@ -65,7 +77,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
@@ -85,7 +97,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
err := runSchema(f, compileCatalog(), "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
@@ -101,7 +113,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -122,7 +134,7 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -156,7 +168,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -195,7 +207,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +255,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
if err := runSchema(f, compileCatalog(), key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -276,9 +288,8 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
@@ -289,7 +300,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
if err := runSchema(f, snap, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
@@ -325,9 +336,8 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
@@ -335,7 +345,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
if err := runSchema(f, snap, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
@@ -349,12 +359,13 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
type out struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
// A compile that succeeds proves the overlay left no orphan pointers; the
|
||||
// entry's output contract carries the resolved schema.
|
||||
snap := compileTestSnapshot(t, eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Schema: eventlib.SchemaDef{
|
||||
@@ -367,13 +378,12 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
def, _ := eventlib.Lookup(syntheticKey)
|
||||
resolved, orphans, err := resolveSchemaJSON(def)
|
||||
if err != nil || len(orphans) != 0 {
|
||||
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
|
||||
entry, ok := snap.Resolve(syntheticKey)
|
||||
if !ok {
|
||||
t.Fatalf("snap.Resolve(%q) should succeed", syntheticKey)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(resolved, &parsed); err != nil {
|
||||
if err := json.Unmarshal(entry.Output().SchemaJSON, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
|
||||
@@ -382,37 +392,35 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
func TestCompile_EmptySpecIsRejected(t *testing.T) {
|
||||
_, err := catalog.Compile([]eventlib.KeyDefinition{{
|
||||
Key: "synthetic.empty.spec",
|
||||
EventType: "synthetic.empty.spec",
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{}},
|
||||
}}, catalog.StrategyRefs{catalog.StrategyNone})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
if !strings.Contains(err.Error(), "exactly one of Type or Raw") {
|
||||
t.Errorf("error should reject the empty spec, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) {
|
||||
_, err := catalog.Compile([]eventlib.KeyDefinition{{
|
||||
Key: "synthetic.invalid.base",
|
||||
EventType: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
}}, catalog.StrategyRefs{catalog.StrategyNone})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
// Garbage raw bytes are rejected by the spec check itself, before the
|
||||
// overlay machinery would even try to parse them.
|
||||
if !strings.Contains(err.Error(), "is not a JSON object") {
|
||||
t.Errorf("error should reject the unparsable base schema, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
85
cmd/event/service_adapters.go
Normal file
85
cmd/event/service_adapters.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
)
|
||||
|
||||
// consumeStrategies is the executable strategy set for this binary. The same
|
||||
// registry is handed to catalog compilation, so a reference the compiler
|
||||
// accepted is guaranteed to resolve here.
|
||||
var consumeStrategies = appconsume.DefaultRegistry()
|
||||
|
||||
type identityResolverFunc func(ctx context.Context, entry *catalog.Entry) (string, error)
|
||||
|
||||
func (f identityResolverFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) {
|
||||
return f(ctx, entry)
|
||||
}
|
||||
|
||||
type preflightReaderFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error)
|
||||
|
||||
func (f preflightReaderFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) {
|
||||
return f(ctx, entry, identity)
|
||||
}
|
||||
|
||||
type streamRunnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error
|
||||
|
||||
func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error {
|
||||
return f(ctx, prepare)
|
||||
}
|
||||
|
||||
// readPreconditions classifies the existing read-only preflight checks into
|
||||
// named preconditions. Weak dependencies that could not answer stay visible
|
||||
// as "unknown" instead of silently passing; a failed check carries the exact
|
||||
// error a real run returns, so refusal is identical on both paths.
|
||||
func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenErr error) []appconsume.Precondition {
|
||||
credentials := appconsume.Precondition{Name: "credentials_available", Status: appconsume.PreconditionOK}
|
||||
if tokenErr != nil {
|
||||
credentials.Status = appconsume.PreconditionBlocked
|
||||
credentials.Detail = tokenErr.Error()
|
||||
credentials.BlockErr = tokenErr
|
||||
}
|
||||
|
||||
console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK}
|
||||
switch {
|
||||
case len(pf.keyDef.RequiredConsoleEvents) == 0:
|
||||
// nothing to verify
|
||||
case pf.keyDef.SubscriptionType == eventlib.SubTypeCallback && pf.subscribedCallbacks == nil,
|
||||
pf.keyDef.SubscriptionType != eventlib.SubTypeCallback && pf.appVer == nil:
|
||||
console.Status = appconsume.PreconditionUnknown
|
||||
if appVerErr != nil {
|
||||
console.Detail = describeAppMetaErr(appVerErr)
|
||||
} else {
|
||||
console.Detail = "console ledger unavailable"
|
||||
}
|
||||
default:
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
console.Status = appconsume.PreconditionBlocked
|
||||
console.Detail = err.Error()
|
||||
console.BlockErr = err
|
||||
}
|
||||
}
|
||||
|
||||
scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK}
|
||||
checked, err := preflightScopes(ctx, pf)
|
||||
switch {
|
||||
case err != nil:
|
||||
scopes.Status = appconsume.PreconditionBlocked
|
||||
scopes.Detail = err.Error()
|
||||
scopes.BlockErr = err
|
||||
case !checked:
|
||||
// The scope ledger could not be read (no published version for bots,
|
||||
// no resolvable token for users). Saying "ok" here would dress up
|
||||
// "nobody looked" as "it was verified".
|
||||
scopes.Status = appconsume.PreconditionUnknown
|
||||
scopes.Detail = "granted scopes could not be read for this identity"
|
||||
}
|
||||
|
||||
return []appconsume.Precondition{credentials, console, scopes}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
|
||||
)
|
||||
|
||||
type fakeScanner struct {
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busctl"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover"
|
||||
)
|
||||
|
||||
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/adapter/localbus/protocol"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
|
||||
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
|
||||
func suggestEventKeys(input string) []string {
|
||||
func suggestEventKeys(snap *catalog.Snapshot, input string) []string {
|
||||
type match struct {
|
||||
key string
|
||||
dist int
|
||||
@@ -24,13 +24,13 @@ func suggestEventKeys(input string) []string {
|
||||
var hits []match
|
||||
threshold := max(2, len(input)/5)
|
||||
|
||||
for _, def := range eventlib.ListAll() {
|
||||
if strings.Contains(def.Key, input) {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
for _, key := range snap.Keys() {
|
||||
if strings.Contains(key, input) {
|
||||
hits = append(hits, match{key, 0})
|
||||
continue
|
||||
}
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
if d := suggest.Levenshtein(input, key); d <= threshold {
|
||||
hits = append(hits, match{key, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
|
||||
@@ -59,9 +59,9 @@ func formatSuggestions(keys []string) string {
|
||||
}
|
||||
|
||||
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
|
||||
func unknownEventKeyErr(key string) error {
|
||||
func unknownEventKeyErr(snap *catalog.Snapshot, key string) error {
|
||||
msg := fmt.Sprintf("unknown EventKey: %s", key)
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
if guesses := suggestEventKeys(snap, key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
|
||||
@@ -6,11 +6,10 @@ package event
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
snap := compileCatalog()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -41,7 +40,7 @@ func TestSuggestEventKeys(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := suggestEventKeys(tc.input)
|
||||
got := suggestEventKeys(snap, tc.input)
|
||||
if tc.wantEmpty {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
@@ -98,7 +97,7 @@ func TestFormatSuggestions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("im.message.recieve_v1")
|
||||
err := unknownEventKeyErr(compileCatalog(), "im.message.recieve_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
@@ -115,7 +114,7 @@ func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
|
||||
err := unknownEventKeyErr(compileCatalog(), "xyzzy_no_such_event_key_at_all")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
3023
cmd/event/testdata/golden/list_json.golden
vendored
Normal file
3023
cmd/event/testdata/golden/list_json.golden
vendored
Normal file
File diff suppressed because it is too large
Load Diff
42
cmd/event/testdata/golden/list_text.golden
vendored
Normal file
42
cmd/event/testdata/golden/list_text.golden
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
KEY AUTH PARAMS DESCRIPTION
|
||||
|
||||
── application ──
|
||||
application.bot.menu_v6 bot 0 Triggered when a user clicks a custom bot menu item whose action is configured as a push event.
|
||||
|
||||
── approval ──
|
||||
approval.instance.status_changed_v4 user 1 Triggered after an approval instance status becomes visible to the requester or approval participants
|
||||
approval.task.status_changed_v4 user 1 Triggered after an approval task status becomes visible to the requester or task approver
|
||||
|
||||
── board ──
|
||||
board.whiteboard.updated_v1 user|bot 1 Pushed when the whiteboard content is updated.
|
||||
|
||||
── card ──
|
||||
card.action.trigger bot 0 Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
|
||||
|
||||
── im ──
|
||||
im.chat.disbanded_v1 bot 0 Triggered after a chat is disbanded
|
||||
im.chat.member.bot.added_v1 bot 0 Triggered when the bot is added to a chat
|
||||
im.chat.member.bot.deleted_v1 bot 0 Triggered after the bot is removed from a chat
|
||||
im.chat.member.user.added_v1 bot 0 Triggered when a new user joins a chat (including topic chats)
|
||||
im.chat.member.user.deleted_v1 bot 0 Triggered when a user leaves or is removed from a chat
|
||||
im.chat.member.user.withdrawn_v1 bot 0 Triggered after a pending user invite is withdrawn
|
||||
im.chat.updated_v1 bot 0 Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
|
||||
im.message.message_read_v1 bot 0 Triggered after a user reads a P2P message sent by the bot
|
||||
im.message.reaction.created_v1 bot 0 Triggered when a reaction is added to a message
|
||||
im.message.reaction.deleted_v1 bot 0 Triggered when a reaction is removed from a message
|
||||
im.message.receive_v1 bot 0 Receive IM messages
|
||||
|
||||
── minutes ──
|
||||
minutes.minute.generated_v1 user 0 Triggered when a minute has been generated
|
||||
|
||||
── task ──
|
||||
task.task.update_user_access_v2 user|bot 0 Triggered when tasks visible to the current user or app are created, deleted, or updated
|
||||
|
||||
── vc ──
|
||||
vc.meeting.participant_meeting_ended_v1 user 0 Triggered when a meeting the current user participates in has ended
|
||||
vc.meeting.participant_meeting_joined_v1 user 0 Triggered when the current user joins a meeting
|
||||
vc.meeting.participant_meeting_started_v1 user 0 Triggered when a meeting the current user participates in has started
|
||||
vc.note.generated_v1 user 0 Triggered when a note has been generated
|
||||
vc.recording.recording_ended_v1 user 0 Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.
|
||||
vc.recording.recording_started_v1 user 0 Triggered when a recording_bean recording starts; only generated when connected to Feishu software.
|
||||
vc.recording.recording_transcript_generated_v1 user 0 Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.
|
||||
127
cmd/event/testdata/golden/schema_board_whiteboard_json.golden
vendored
Normal file
127
cmd/event/testdata/golden/schema_board_whiteboard_json.golden
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"key": "board.whiteboard.updated_v1",
|
||||
"display_name": "Whiteboard updated",
|
||||
"description": "Pushed when the whiteboard content is updated.",
|
||||
"event_type": "board.whiteboard.updated_v1",
|
||||
"subscription_type": "event",
|
||||
"params": [
|
||||
{
|
||||
"name": "whiteboard_id",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
"subscription_key": true
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"native": {},
|
||||
"field_overrides": {
|
||||
"/event/operator_ids/*/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/operator_ids/*/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/operator_ids/*/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
},
|
||||
"/event/whiteboard_id": {
|
||||
"Description": "whiteboard id to subscribe",
|
||||
"Enum": null,
|
||||
"Kind": "whiteboard_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scopes": [
|
||||
"board:whiteboard:node:read"
|
||||
],
|
||||
"auth_types": [
|
||||
"user",
|
||||
"bot"
|
||||
],
|
||||
"required_console_events": [
|
||||
"board.whiteboard.updated_v1"
|
||||
],
|
||||
"buffer_size": 100,
|
||||
"workers": 1,
|
||||
"resolved_output_schema": {
|
||||
"description": "飞书事件",
|
||||
"properties": {
|
||||
"event": {
|
||||
"properties": {
|
||||
"operator_ids": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"whiteboard_id": {
|
||||
"description": "whiteboard id to subscribe",
|
||||
"format": "whiteboard_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"header": {
|
||||
"description": "事件头,所有事件结构一致",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"description": "接收事件的应用 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"create_time": {
|
||||
"description": "事件创建时间,毫秒时间戳字符串",
|
||||
"type": "string"
|
||||
},
|
||||
"event_id": {
|
||||
"description": "事件唯一 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"event_type": {
|
||||
"description": "事件类型,用于路由",
|
||||
"type": "string"
|
||||
},
|
||||
"tenant_key": {
|
||||
"description": "租户唯一标识",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "回调校验 token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"schema": {
|
||||
"description": "飞书事件协议版本",
|
||||
"enum": [
|
||||
"2.0"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"jq_root_path": ".event"
|
||||
}
|
||||
89
cmd/event/testdata/golden/schema_board_whiteboard_text.golden
vendored
Normal file
89
cmd/event/testdata/golden/schema_board_whiteboard_text.golden
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
Key: board.whiteboard.updated_v1
|
||||
Description: Pushed when the whiteboard content is updated.
|
||||
Event: board.whiteboard.updated_v1
|
||||
Pre-consume: yes
|
||||
|
||||
Required Scopes:
|
||||
- board:whiteboard:node:read
|
||||
|
||||
Required Console Events (must be enabled in developer console):
|
||||
- board.whiteboard.updated_v1
|
||||
|
||||
Parameters:
|
||||
NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION
|
||||
whiteboard_id string yes yes - Whiteboard id to subscribe; subscription is per-whiteboard.
|
||||
|
||||
Output Schema:
|
||||
{
|
||||
"description": "飞书事件",
|
||||
"properties": {
|
||||
"event": {
|
||||
"properties": {
|
||||
"operator_ids": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"whiteboard_id": {
|
||||
"description": "whiteboard id to subscribe",
|
||||
"format": "whiteboard_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"header": {
|
||||
"description": "事件头,所有事件结构一致",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"description": "接收事件的应用 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"create_time": {
|
||||
"description": "事件创建时间,毫秒时间戳字符串",
|
||||
"type": "string"
|
||||
},
|
||||
"event_id": {
|
||||
"description": "事件唯一 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"event_type": {
|
||||
"description": "事件类型,用于路由",
|
||||
"type": "string"
|
||||
},
|
||||
"tenant_key": {
|
||||
"description": "租户唯一标识",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "回调校验 token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"schema": {
|
||||
"description": "飞书事件协议版本",
|
||||
"enum": [
|
||||
"2.0"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
104
cmd/event/testdata/golden/schema_card_action_trigger_json.golden
vendored
Normal file
104
cmd/event/testdata/golden/schema_card_action_trigger_json.golden
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"key": "card.action.trigger",
|
||||
"display_name": "Card action",
|
||||
"description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
"event_type": "card.action.trigger",
|
||||
"subscription_type": "callback",
|
||||
"schema": {
|
||||
"custom": {}
|
||||
},
|
||||
"scopes": [
|
||||
"im:message:readonly"
|
||||
],
|
||||
"auth_types": [
|
||||
"bot"
|
||||
],
|
||||
"required_console_events": [
|
||||
"card.action.trigger"
|
||||
],
|
||||
"buffer_size": 100,
|
||||
"workers": 1,
|
||||
"single_consumer": true,
|
||||
"resolved_output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action_name": {
|
||||
"type": "string",
|
||||
"description": "Element name attribute"
|
||||
},
|
||||
"action_tag": {
|
||||
"type": "string",
|
||||
"description": "Triggered element type: button/select_static/input/checker/etc"
|
||||
},
|
||||
"action_value": {
|
||||
"type": "string",
|
||||
"description": "Developer-defined action value as JSON string"
|
||||
},
|
||||
"card_content": {
|
||||
"type": "string",
|
||||
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"description": "Chat ID",
|
||||
"format": "chat_id"
|
||||
},
|
||||
"checked": {
|
||||
"type": "boolean",
|
||||
"description": "Checkbox state (for checkbox elements)"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Globally unique event ID"
|
||||
},
|
||||
"form_value": {
|
||||
"type": "string",
|
||||
"description": "Form submission values as JSON string (only on form submit)"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Host type: im_message / im_top_notice"
|
||||
},
|
||||
"input_value": {
|
||||
"type": "string",
|
||||
"description": "Input field value (only for input elements)"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Message ID of the card",
|
||||
"format": "message_id"
|
||||
},
|
||||
"operator_id": {
|
||||
"type": "string",
|
||||
"description": "Operator open_id",
|
||||
"format": "open_id"
|
||||
},
|
||||
"option": {
|
||||
"type": "string",
|
||||
"description": "Selected option value (for single-select dropdown)"
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Selected options, comma-separated (for multi-select)"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Event delivery time (ms timestamp string)",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "User timezone for date/time picker interactions"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Token for delay card update (valid 30 min, max 2 updates)"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Event type; always card.action.trigger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jq_root_path": "."
|
||||
}
|
||||
92
cmd/event/testdata/golden/schema_card_action_trigger_text.golden
vendored
Normal file
92
cmd/event/testdata/golden/schema_card_action_trigger_text.golden
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
Key: card.action.trigger
|
||||
Description: Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).
|
||||
Event: card.action.trigger
|
||||
|
||||
Required Scopes:
|
||||
- im:message:readonly
|
||||
|
||||
Required Console Events (must be enabled in developer console):
|
||||
- card.action.trigger
|
||||
|
||||
Output Schema:
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action_name": {
|
||||
"type": "string",
|
||||
"description": "Element name attribute"
|
||||
},
|
||||
"action_tag": {
|
||||
"type": "string",
|
||||
"description": "Triggered element type: button/select_static/input/checker/etc"
|
||||
},
|
||||
"action_value": {
|
||||
"type": "string",
|
||||
"description": "Developer-defined action value as JSON string"
|
||||
},
|
||||
"card_content": {
|
||||
"type": "string",
|
||||
"description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"description": "Chat ID",
|
||||
"format": "chat_id"
|
||||
},
|
||||
"checked": {
|
||||
"type": "boolean",
|
||||
"description": "Checkbox state (for checkbox elements)"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Globally unique event ID"
|
||||
},
|
||||
"form_value": {
|
||||
"type": "string",
|
||||
"description": "Form submission values as JSON string (only on form submit)"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Host type: im_message / im_top_notice"
|
||||
},
|
||||
"input_value": {
|
||||
"type": "string",
|
||||
"description": "Input field value (only for input elements)"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Message ID of the card",
|
||||
"format": "message_id"
|
||||
},
|
||||
"operator_id": {
|
||||
"type": "string",
|
||||
"description": "Operator open_id",
|
||||
"format": "open_id"
|
||||
},
|
||||
"option": {
|
||||
"type": "string",
|
||||
"description": "Selected option value (for single-select dropdown)"
|
||||
},
|
||||
"options": {
|
||||
"type": "string",
|
||||
"description": "Selected options, comma-separated (for multi-select)"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Event delivery time (ms timestamp string)",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "User timezone for date/time picker interactions"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Token for delay card update (valid 30 min, max 2 updates)"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Event type; always card.action.trigger"
|
||||
}
|
||||
}
|
||||
}
|
||||
430
cmd/event/testdata/golden/schema_im_chat_updated_json.golden
vendored
Normal file
430
cmd/event/testdata/golden/schema_im_chat_updated_json.golden
vendored
Normal file
@@ -0,0 +1,430 @@
|
||||
{
|
||||
"key": "im.chat.updated_v1",
|
||||
"display_name": "Chat updated",
|
||||
"description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
|
||||
"event_type": "im.chat.updated_v1",
|
||||
"subscription_type": "event",
|
||||
"schema": {
|
||||
"native": {},
|
||||
"field_overrides": {
|
||||
"/event/after_change/owner_id/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/after_change/owner_id/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/after_change/owner_id/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
},
|
||||
"/event/before_change/owner_id/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/before_change/owner_id/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/before_change/owner_id/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
},
|
||||
"/event/chat_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "chat_id"
|
||||
},
|
||||
"/event/moderator_list/added_member_list/*/user_id/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/moderator_list/added_member_list/*/user_id/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/moderator_list/added_member_list/*/user_id/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
},
|
||||
"/event/moderator_list/removed_member_list/*/user_id/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/moderator_list/removed_member_list/*/user_id/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/moderator_list/removed_member_list/*/user_id/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
},
|
||||
"/event/operator_id/open_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "open_id"
|
||||
},
|
||||
"/event/operator_id/union_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "union_id"
|
||||
},
|
||||
"/event/operator_id/user_id": {
|
||||
"Description": "",
|
||||
"Enum": null,
|
||||
"Kind": "user_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scopes": [
|
||||
"im:chat:read"
|
||||
],
|
||||
"auth_types": [
|
||||
"bot"
|
||||
],
|
||||
"required_console_events": [
|
||||
"im.chat.updated_v1"
|
||||
],
|
||||
"buffer_size": 100,
|
||||
"workers": 1,
|
||||
"resolved_output_schema": {
|
||||
"description": "飞书事件",
|
||||
"properties": {
|
||||
"event": {
|
||||
"properties": {
|
||||
"after_change": {
|
||||
"properties": {
|
||||
"add_member_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"at_all_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"edit_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_message_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"i18n_names": {
|
||||
"properties": {
|
||||
"en_us": {
|
||||
"type": "string"
|
||||
},
|
||||
"ja_jp": {
|
||||
"type": "string"
|
||||
},
|
||||
"zh_cn": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"join_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"leave_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"membership_approval": {
|
||||
"type": "string"
|
||||
},
|
||||
"moderation_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"restricted_mode_setting": {
|
||||
"properties": {
|
||||
"download_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"screenshot_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"share_card_permission": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"before_change": {
|
||||
"properties": {
|
||||
"add_member_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"at_all_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"edit_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_message_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"i18n_names": {
|
||||
"properties": {
|
||||
"en_us": {
|
||||
"type": "string"
|
||||
},
|
||||
"ja_jp": {
|
||||
"type": "string"
|
||||
},
|
||||
"zh_cn": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"join_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"leave_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"membership_approval": {
|
||||
"type": "string"
|
||||
},
|
||||
"moderation_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"restricted_mode_setting": {
|
||||
"properties": {
|
||||
"download_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"screenshot_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"share_card_permission": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"chat_id": {
|
||||
"format": "chat_id",
|
||||
"type": "string"
|
||||
},
|
||||
"external": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"moderator_list": {
|
||||
"properties": {
|
||||
"added_member_list": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"tenant_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"removed_member_list": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"tenant_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"operator_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"operator_tenant_key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"header": {
|
||||
"description": "事件头,所有事件结构一致",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"description": "接收事件的应用 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"create_time": {
|
||||
"description": "事件创建时间,毫秒时间戳字符串",
|
||||
"type": "string"
|
||||
},
|
||||
"event_id": {
|
||||
"description": "事件唯一 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"event_type": {
|
||||
"description": "事件类型,用于路由",
|
||||
"type": "string"
|
||||
},
|
||||
"tenant_key": {
|
||||
"description": "租户唯一标识",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "回调校验 token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"schema": {
|
||||
"description": "飞书事件协议版本",
|
||||
"enum": [
|
||||
"2.0"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"jq_root_path": ".event"
|
||||
}
|
||||
337
cmd/event/testdata/golden/schema_im_chat_updated_text.golden
vendored
Normal file
337
cmd/event/testdata/golden/schema_im_chat_updated_text.golden
vendored
Normal file
@@ -0,0 +1,337 @@
|
||||
Key: im.chat.updated_v1
|
||||
Description: Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated
|
||||
Event: im.chat.updated_v1
|
||||
|
||||
Required Scopes:
|
||||
- im:chat:read
|
||||
|
||||
Required Console Events (must be enabled in developer console):
|
||||
- im.chat.updated_v1
|
||||
|
||||
Output Schema:
|
||||
{
|
||||
"description": "飞书事件",
|
||||
"properties": {
|
||||
"event": {
|
||||
"properties": {
|
||||
"after_change": {
|
||||
"properties": {
|
||||
"add_member_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"at_all_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"edit_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_message_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"i18n_names": {
|
||||
"properties": {
|
||||
"en_us": {
|
||||
"type": "string"
|
||||
},
|
||||
"ja_jp": {
|
||||
"type": "string"
|
||||
},
|
||||
"zh_cn": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"join_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"leave_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"membership_approval": {
|
||||
"type": "string"
|
||||
},
|
||||
"moderation_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"restricted_mode_setting": {
|
||||
"properties": {
|
||||
"download_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"screenshot_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"share_card_permission": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"before_change": {
|
||||
"properties": {
|
||||
"add_member_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"at_all_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"edit_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_message_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"i18n_names": {
|
||||
"properties": {
|
||||
"en_us": {
|
||||
"type": "string"
|
||||
},
|
||||
"ja_jp": {
|
||||
"type": "string"
|
||||
},
|
||||
"zh_cn": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"join_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"leave_message_visibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"membership_approval": {
|
||||
"type": "string"
|
||||
},
|
||||
"moderation_permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"restricted_mode_setting": {
|
||||
"properties": {
|
||||
"download_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"screenshot_has_permission_setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"share_card_permission": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"chat_id": {
|
||||
"format": "chat_id",
|
||||
"type": "string"
|
||||
},
|
||||
"external": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"moderator_list": {
|
||||
"properties": {
|
||||
"added_member_list": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"tenant_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"removed_member_list": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"tenant_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"operator_id": {
|
||||
"properties": {
|
||||
"open_id": {
|
||||
"format": "open_id",
|
||||
"type": "string"
|
||||
},
|
||||
"union_id": {
|
||||
"format": "union_id",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"format": "user_id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"operator_tenant_key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"header": {
|
||||
"description": "事件头,所有事件结构一致",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"description": "接收事件的应用 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"create_time": {
|
||||
"description": "事件创建时间,毫秒时间戳字符串",
|
||||
"type": "string"
|
||||
},
|
||||
"event_id": {
|
||||
"description": "事件唯一 ID",
|
||||
"type": "string"
|
||||
},
|
||||
"event_type": {
|
||||
"description": "事件类型,用于路由",
|
||||
"type": "string"
|
||||
},
|
||||
"tenant_key": {
|
||||
"description": "租户唯一标识",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "回调校验 token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"schema": {
|
||||
"description": "飞书事件协议版本",
|
||||
"enum": [
|
||||
"2.0"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
130
cmd/event/testdata/golden/schema_im_message_receive_json.golden
vendored
Normal file
130
cmd/event/testdata/golden/schema_im_message_receive_json.golden
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"key": "im.message.receive_v1",
|
||||
"display_name": "Receive message",
|
||||
"description": "Receive IM messages",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"subscription_type": "event",
|
||||
"schema": {
|
||||
"custom": {}
|
||||
},
|
||||
"scopes": [
|
||||
"im:message.p2p_msg:readonly"
|
||||
],
|
||||
"auth_types": [
|
||||
"bot"
|
||||
],
|
||||
"required_console_events": [
|
||||
"im.message.receive_v1"
|
||||
],
|
||||
"buffer_size": 100,
|
||||
"workers": 1,
|
||||
"resolved_output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"description": "Chat/conversation ID; prefixed with oc_",
|
||||
"format": "chat_id"
|
||||
},
|
||||
"chat_type": {
|
||||
"type": "string",
|
||||
"description": "Conversation type",
|
||||
"enum": [
|
||||
"p2p",
|
||||
"group"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
|
||||
},
|
||||
"create_time": {
|
||||
"type": "string",
|
||||
"description": "Message creation time (ms timestamp string)",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
|
||||
"format": "message_id"
|
||||
},
|
||||
"mentions": {
|
||||
"type": "array",
|
||||
"description": "Compact mentions aligned with im +messages-mget",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Mentioned user open_id; prefixed with ou_",
|
||||
"format": "open_id"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Mention placeholder key, for example @_user_1"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Mentioned display name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
|
||||
"format": "message_id"
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Message type"
|
||||
},
|
||||
"reply_to": {
|
||||
"type": "string",
|
||||
"description": "Parent message ID of the direct reply context, when present",
|
||||
"format": "message_id"
|
||||
},
|
||||
"root_id": {
|
||||
"type": "string",
|
||||
"description": "Root message ID of the reply/thread context, when present",
|
||||
"format": "message_id"
|
||||
},
|
||||
"sender_id": {
|
||||
"type": "string",
|
||||
"description": "Sender open_id; prefixed with ou_",
|
||||
"format": "open_id"
|
||||
},
|
||||
"sender_type": {
|
||||
"type": "string",
|
||||
"description": "Sender type",
|
||||
"enum": [
|
||||
"user",
|
||||
"bot"
|
||||
]
|
||||
},
|
||||
"thread_id": {
|
||||
"type": "string",
|
||||
"description": "Thread ID, when present"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Event type; always im.message.receive_v1"
|
||||
},
|
||||
"update_time": {
|
||||
"type": "string",
|
||||
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
|
||||
"format": "timestamp_ms"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jq_root_path": "."
|
||||
}
|
||||
119
cmd/event/testdata/golden/schema_im_message_receive_text.golden
vendored
Normal file
119
cmd/event/testdata/golden/schema_im_message_receive_text.golden
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
Key: im.message.receive_v1
|
||||
Description: Receive IM messages
|
||||
Event: im.message.receive_v1
|
||||
|
||||
Required Scopes:
|
||||
- im:message.p2p_msg:readonly
|
||||
|
||||
Required Console Events (must be enabled in developer console):
|
||||
- im.message.receive_v1
|
||||
|
||||
Output Schema:
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"description": "Chat/conversation ID; prefixed with oc_",
|
||||
"format": "chat_id"
|
||||
},
|
||||
"chat_type": {
|
||||
"type": "string",
|
||||
"description": "Conversation type",
|
||||
"enum": [
|
||||
"p2p",
|
||||
"group"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."
|
||||
},
|
||||
"create_time": {
|
||||
"type": "string",
|
||||
"description": "Message creation time (ms timestamp string)",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Message ID (legacy alias of message_id, kept for compatibility)",
|
||||
"format": "message_id"
|
||||
},
|
||||
"mentions": {
|
||||
"type": "array",
|
||||
"description": "Compact mentions aligned with im +messages-mget",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Mentioned user open_id; prefixed with ou_",
|
||||
"format": "open_id"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Mention placeholder key, for example @_user_1"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Mentioned display name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.",
|
||||
"format": "message_id"
|
||||
},
|
||||
"message_type": {
|
||||
"type": "string",
|
||||
"description": "Message type"
|
||||
},
|
||||
"reply_to": {
|
||||
"type": "string",
|
||||
"description": "Parent message ID of the direct reply context, when present",
|
||||
"format": "message_id"
|
||||
},
|
||||
"root_id": {
|
||||
"type": "string",
|
||||
"description": "Root message ID of the reply/thread context, when present",
|
||||
"format": "message_id"
|
||||
},
|
||||
"sender_id": {
|
||||
"type": "string",
|
||||
"description": "Sender open_id; prefixed with ou_",
|
||||
"format": "open_id"
|
||||
},
|
||||
"sender_type": {
|
||||
"type": "string",
|
||||
"description": "Sender type",
|
||||
"enum": [
|
||||
"user",
|
||||
"bot"
|
||||
]
|
||||
},
|
||||
"thread_id": {
|
||||
"type": "string",
|
||||
"description": "Thread ID, when present"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Event delivery time (ms timestamp string); prefers header.create_time",
|
||||
"format": "timestamp_ms"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Event type; always im.message.receive_v1"
|
||||
},
|
||||
"update_time": {
|
||||
"type": "string",
|
||||
"description": "Message update time (ms timestamp string); emitted only when different from create_time",
|
||||
"format": "timestamp_ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
cmd/event/wiring.go
Normal file
25
cmd/event/wiring.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/event/catalog"
|
||||
)
|
||||
|
||||
// compileCatalog is the event command tree's single assembly point: it turns
|
||||
// the aggregated domain declarations into the immutable snapshot every
|
||||
// subcommand reads. A compile failure is a defect in declarations built into
|
||||
// this binary — there is nothing to recover at runtime, so it panics.
|
||||
func compileCatalog() *catalog.Snapshot {
|
||||
// The strategy registry that validates references is the same one that
|
||||
// executes them, so "compiled" implies "resolvable at run time".
|
||||
snap, err := catalog.Compile(events.All(), consumeStrategies)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("event catalog failed to compile: %v", err))
|
||||
}
|
||||
return snap
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
@@ -15,26 +14,13 @@ 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.",
|
||||
})
|
||||
|
||||
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(NewCmdProfileList(f))
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
16
cmd/root.go
16
cmd/root.go
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/runtimebootstrap"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -101,12 +100,6 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
// Resolve all startup state from the detected workspace. This must happen
|
||||
// before ResolveStartupBrand, isSingleAppMode, or buildInternal reads
|
||||
// workspace-scoped configuration.
|
||||
selectInvocationWorkspace()
|
||||
startup := runtimebootstrap.Resolve(inv.Profile)
|
||||
startupBrand := resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -114,8 +107,7 @@ func Execute() int {
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(startupBrand),
|
||||
withRuntimeBootstrap(startup),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -145,7 +137,7 @@ func Execute() int {
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := checkCachedEditionUpdate(build.Version); info != nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
ver := build.Version
|
||||
@@ -155,9 +147,9 @@ func setupNotices() {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
refreshEditionUpdateCache(ver)
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := checkCachedEditionUpdate(ver); info != nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -436,25 +435,6 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_BlockErrorPreservesUntypedFallback(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
blockErr := &extcred.BlockError{Provider: "env", Reason: "LARKSUITE_CLI_APP_ID is missing"}
|
||||
|
||||
exit := handleRootError(f, blockErr)
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if errObj["type"] != "internal" || errObj["subtype"] != "unknown" {
|
||||
t.Fatalf("error = %#v", errObj)
|
||||
}
|
||||
if errObj["message"] != "blocked by env: LARKSUITE_CLI_APP_ID is missing" {
|
||||
t.Fatalf("error.message = %v", errObj["message"])
|
||||
}
|
||||
if exit != int(output.ExitInternal) {
|
||||
t.Fatalf("exit = %d, want %d", exit, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -57,14 +58,24 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||
return
|
||||
}
|
||||
// Gate 4: cached newer version from this binary's release channel.
|
||||
// Standard reads the npm-backed cache; Extended reads its separate
|
||||
// GitHub-release cache.
|
||||
info := checkCachedEditionUpdate(build.Version)
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,21 +15,13 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func updateStateFileForEdition(edition string) string {
|
||||
if edition == "extended" {
|
||||
return "update-state-extended.json"
|
||||
}
|
||||
return "update-state.json"
|
||||
}
|
||||
|
||||
func writeUpdateState(t *testing.T, dir, edition, latest string) {
|
||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||
if err := vfs.WriteFile(filepath.Join(dir, updateStateFileForEdition(edition)), []byte(data), 0o600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +105,7 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
if tc.latest != "" {
|
||||
writeUpdateState(t, dir, build.Edition, tc.latest)
|
||||
writeUpdateState(t, dir, tc.latest)
|
||||
}
|
||||
if tc.optOut {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
@@ -135,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
// The prompt must not name a target version: info.Latest comes from
|
||||
// the on-disk cache and can be stale, while the version actually
|
||||
// installed is resolved live by the update subcommand.
|
||||
if tc.wantPrompt {
|
||||
if strings.Contains(errBuf.String(), tc.latest) {
|
||||
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), build.Version) {
|
||||
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||
}
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
@@ -142,53 +146,6 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgradeIgnoresOtherEditionCache(t *testing.T) {
|
||||
origV := build.Version
|
||||
build.Version = "1.0.0"
|
||||
t.Cleanup(func() { build.Version = origV })
|
||||
|
||||
origRun := runRootUpgrade
|
||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||
|
||||
origWS := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILD_NUMBER", "")
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
|
||||
otherEdition := "extended"
|
||||
if build.Edition == "extended" {
|
||||
otherEdition = "standard"
|
||||
}
|
||||
writeUpdateState(t, dir, otherEdition, "9.0.0")
|
||||
|
||||
called := false
|
||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader("y\n"),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &errBuf,
|
||||
IsTerminal: true,
|
||||
OutIsTerminal: true,
|
||||
StderrIsTerminal: true,
|
||||
}}
|
||||
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
if strings.Contains(errBuf.String(), "available") {
|
||||
t.Fatalf("%s prompt consumed %s cache: %q", build.Edition, otherEdition, errBuf.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("%s upgrade ran from %s cache", build.Edition, otherEdition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
|
||||
@@ -10,34 +10,17 @@ import (
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// selectInvocationWorkspace establishes the workspace before any startup
|
||||
// consumer reads workspace-scoped configuration. Execute needs this before
|
||||
// resolving the registry brand, while Build/buildInternal needs it before
|
||||
// capturing the immutable Profile snapshot passed to the Factory.
|
||||
func selectInvocationWorkspace() core.Workspace {
|
||||
workspace := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(workspace)
|
||||
return workspace
|
||||
}
|
||||
|
||||
// ResolveStartupBrand resolves the brand before the command tree is built, so
|
||||
// the registry's remote metadata overlay uses the configured brand from the
|
||||
// first catalog access. It mirrors the credential chain's brand precedence —
|
||||
// environment, then the active profile's raw config entry — without touching
|
||||
// the keychain (no secrets are needed to know the brand).
|
||||
func ResolveStartupBrand(profile string) core.LarkBrand {
|
||||
config, _ := core.LoadMultiAppConfig()
|
||||
return resolveStartupBrandFromConfig(profile, config)
|
||||
}
|
||||
|
||||
// resolveStartupBrandFromConfig keeps registry routing on the same immutable
|
||||
// Profile snapshot used by credentials and runtime policy.
|
||||
func resolveStartupBrandFromConfig(profile string, config *core.MultiAppConfig) core.LarkBrand {
|
||||
if raw := os.Getenv(envvars.CliBrand); raw != "" {
|
||||
return core.ParseBrand(raw)
|
||||
}
|
||||
if config != nil {
|
||||
if app := config.CurrentAppConfig(profile); app != nil {
|
||||
if cfg, err := core.LoadMultiAppConfig(); err == nil {
|
||||
if app := cfg.CurrentAppConfig(profile); app != nil {
|
||||
return core.ParseBrand(string(app.Brand))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/extendedupdate"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
var (
|
||||
fetchExtendedLatest = extendedupdate.FetchLatest
|
||||
installExtended = extendedupdate.Install
|
||||
)
|
||||
|
||||
func updateLongDescription() string {
|
||||
return `Update lark-cli Extended from the matching GitHub Release.
|
||||
|
||||
The command downloads the lark-cli-extended asset for the current platform,
|
||||
verifies its SHA-256 checksum and compiled edition identity, then replaces the
|
||||
current binary. It never installs the Standard npm/npx edition.
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`
|
||||
}
|
||||
|
||||
func runEditionUpdate(opts *UpdateOptions) (bool, error) {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
if !opts.Check {
|
||||
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
output.PendingNotice = nil
|
||||
|
||||
latest, err := fetchExtendedLatest()
|
||||
if err != nil {
|
||||
var typed errs.TypedError
|
||||
if errors.As(err, &typed) {
|
||||
return true, reportError(opts, io, "network", typed)
|
||||
}
|
||||
return true, reportError(opts, io, "network",
|
||||
errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"failed to check the latest Extended version: %v", err).WithCause(err))
|
||||
}
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return true, reportError(opts, io, "update_error",
|
||||
errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"invalid Extended version from GitHub Releases: %s", latest))
|
||||
}
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return true, reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
if opts.Check {
|
||||
return true, reportCheckResult(opts, io, cur, latest, true)
|
||||
}
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli Extended %s %s %s from GitHub Releases ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
if err := installExtended(latest); err != nil {
|
||||
var typed errs.TypedError
|
||||
if errors.As(err, &typed) {
|
||||
return true, reportError(opts, io, "update_error", typed)
|
||||
}
|
||||
return true, reportError(opts, io, "update_error",
|
||||
errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to install lark-cli Extended: %v", err).WithCause(err))
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": latest,
|
||||
"latest_version": latest, "edition": build.Edition, "action": "updated",
|
||||
"message": fmt.Sprintf("lark-cli Extended updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
applySkillsResult(result, skillsResult)
|
||||
output.PrintJson(io.Out, result)
|
||||
return true, nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli Extended from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
func TestExtendedUpdateUsesExtendedReleaseInstaller(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldFetch, oldInstall := fetchExtendedLatest, installExtended
|
||||
oldVersion, oldUpdater, oldSync := currentVersion, newUpdater, syncSkills
|
||||
t.Cleanup(func() {
|
||||
fetchExtendedLatest, installExtended = oldFetch, oldInstall
|
||||
currentVersion, newUpdater, syncSkills = oldVersion, oldUpdater, oldSync
|
||||
})
|
||||
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
|
||||
currentVersion = func() string { return "1.2.3" }
|
||||
installed := ""
|
||||
installExtended = func(version string) error {
|
||||
installed = version
|
||||
return nil
|
||||
}
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallManual}
|
||||
}}
|
||||
}
|
||||
syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { return &skillscheck.SyncResult{} }
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &errOut), cmdutil.InvocationContext{})
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != "1.2.4" {
|
||||
t.Fatalf("installed version = %q, want 1.2.4", installed)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["edition"] != "extended" || result["action"] != "updated" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendedUpdateCheckDoesNotInstall(t *testing.T) {
|
||||
oldFetch, oldInstall := fetchExtendedLatest, installExtended
|
||||
oldVersion := currentVersion
|
||||
t.Cleanup(func() {
|
||||
fetchExtendedLatest, installExtended = oldFetch, oldInstall
|
||||
currentVersion = oldVersion
|
||||
})
|
||||
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
|
||||
currentVersion = func() string { return "1.2.3" }
|
||||
installExtended = func(string) error {
|
||||
t.Fatal("installer called during --check")
|
||||
return nil
|
||||
}
|
||||
var out bytes.Buffer
|
||||
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &bytes.Buffer{}), cmdutil.InvocationContext{})
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
if err := cmd.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmdupdate
|
||||
|
||||
func runEditionUpdate(*UpdateOptions) (bool, error) { return false, nil }
|
||||
|
||||
func updateLongDescription() string {
|
||||
return `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`
|
||||
}
|
||||
@@ -101,7 +101,15 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update lark-cli to the latest version",
|
||||
Long: updateLongDescription(),
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return updateRun(opts)
|
||||
},
|
||||
@@ -116,9 +124,6 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
if handled, err := runEditionUpdate(opts); handled {
|
||||
return err
|
||||
}
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/extendedupdate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
|
||||
return extendedupdate.CheckCached(currentVersion)
|
||||
}
|
||||
|
||||
func refreshEditionUpdateCache(currentVersion string) {
|
||||
extendedupdate.RefreshCache(currentVersion)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/larksuite/cli/internal/update"
|
||||
|
||||
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
|
||||
return update.CheckCached(currentVersion)
|
||||
}
|
||||
|
||||
func refreshEditionUpdateCache(currentVersion string) {
|
||||
update.RefreshCache(currentVersion)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
factory *cmdutil.Factory
|
||||
json bool
|
||||
}
|
||||
|
||||
type versionReport struct {
|
||||
Version string `json:"version"`
|
||||
Edition string `json:"edition"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// NewCmdVersion reports the immutable edition identity compiled into the
|
||||
// binary. Root --version remains unchanged for compatibility.
|
||||
func NewCmdVersion(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &options{factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version and edition information",
|
||||
Hidden: hideVersionCommand(),
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.json {
|
||||
output.PrintJson(opts.factory.IOStreams.Out, versionReport{
|
||||
Version: build.Version,
|
||||
Edition: build.Edition,
|
||||
Capabilities: build.Capabilities(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(opts.factory.IOStreams.Out, "lark-cli version %s (%s)\n", build.Version, build.Edition)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write version output: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.json, "json", false, "structured JSON output")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package version
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestVersionJSONReportsCompiledEdition(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdVersion(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got struct {
|
||||
Version string `json:"version"`
|
||||
Edition string `json:"edition"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Version != build.Version || got.Edition != build.Edition || !reflect.DeepEqual(got.Capabilities, build.Capabilities()) {
|
||||
t.Fatalf("version output = %#v, want version=%q edition=%q", got, build.Version, build.Edition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionVisibilityPreservesStandardHelpSurface(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdVersion(f)
|
||||
wantHidden := build.Edition == "standard"
|
||||
if cmd.Hidden != wantHidden {
|
||||
t.Fatalf("version command hidden = %v, want %v for %s", cmd.Hidden, wantHidden, build.Edition)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{ err error }
|
||||
|
||||
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestVersionTextWriteFailureIsTyped(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
writeErr := errors.New("write failed")
|
||||
f.IOStreams.Out = failingWriter{err: writeErr}
|
||||
|
||||
cmd := NewCmdVersion(f)
|
||||
err := cmd.ExecuteContext(context.Background())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("error = %#v, want internal/sdk_error", err)
|
||||
}
|
||||
if !errors.Is(err, writeErr) {
|
||||
t.Fatalf("error does not preserve write failure: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package version
|
||||
|
||||
func hideVersionCommand() bool { return false }
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package version
|
||||
|
||||
// Standard keeps its historical help surface unchanged. The command remains
|
||||
// directly callable for release identity verification.
|
||||
func hideVersionCommand() bool { return true }
|
||||
@@ -62,8 +62,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
|
||||
| `error.message` | informational | not safe to branch on |
|
||||
| `error.hint` | informational | actionable recovery guidance |
|
||||
| `error.log_id` | informational | upstream request id (server-side trace) |
|
||||
| `error.origin` | informational | Extended producer: `cli`, `credential_process`, `proxy`, or `lark`; omitted by Standard to preserve its existing envelope; consumers must tolerate absence and unknown future values |
|
||||
| `error.proxy_request_id` | informational | external credential platform trace id; never stored in `log_id` |
|
||||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
||||
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
|
||||
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
|
||||
@@ -106,7 +104,7 @@ already succeeded).
|
||||
| `config` | local config missing / unbound | 3 | `ConfigError` |
|
||||
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
|
||||
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
|
||||
| `policy` | security policy denial/challenge, including content safety | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
||||
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
||||
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
|
||||
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
|
||||
|
||||
@@ -274,7 +272,7 @@ legal for framework dynamic paths (e.g. classifier fanout) but the lint
|
||||
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
|
||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
|
||||
| Transport or external dependency failure | `errs.NewNetworkError(subtype, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `credential_source_unavailable` / `upstream_unavailable`) |
|
||||
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
|
||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
|
||||
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
|
||||
@@ -515,11 +513,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
|
||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||
typed struct as a documented extension field, not into the envelope's
|
||||
top level. The external credential platform contract is the single explicit
|
||||
exception: `origin` and `proxy_request_id` are shared across several error
|
||||
categories and therefore live in `Problem`. Both fields are optional, and
|
||||
consumers must ignore them when absent or unknown. Any further shared field
|
||||
still requires an explicit contract revision and wire-format pin.
|
||||
top level.
|
||||
|
||||
## CI guards
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// DiagnosticMetadata carries optional producer diagnostics without changing
|
||||
// the field layout of Problem or any concrete typed error. Keeping this
|
||||
// metadata in a wrapper preserves source compatibility for callers that use
|
||||
// positional literals of the existing exported error structs.
|
||||
type DiagnosticMetadata struct {
|
||||
Origin string
|
||||
ProxyRequestID string
|
||||
}
|
||||
|
||||
type diagnosticMetadataWrapper struct {
|
||||
err error
|
||||
typed error
|
||||
metadata DiagnosticMetadata
|
||||
}
|
||||
|
||||
func (e *diagnosticMetadataWrapper) Error() string {
|
||||
if e == nil || e.err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *diagnosticMetadataWrapper) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *diagnosticMetadataWrapper) ProblemDetail() *Problem {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
problem, _ := ProblemOf(e.typed)
|
||||
return problem
|
||||
}
|
||||
|
||||
func (e *diagnosticMetadataWrapper) DiagnosticMetadata() DiagnosticMetadata {
|
||||
if e == nil {
|
||||
return DiagnosticMetadata{}
|
||||
}
|
||||
return e.metadata
|
||||
}
|
||||
|
||||
// MarshalJSON preserves the concrete typed error's extension fields and adds
|
||||
// the optional diagnostics as sibling fields in the existing error object.
|
||||
func (e *diagnosticMetadataWrapper) MarshalJSON() ([]byte, error) {
|
||||
raw, err := json.Marshal(e.typed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.metadata.Origin != "" {
|
||||
origin, err := json.Marshal(e.metadata.Origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object["origin"] = origin
|
||||
}
|
||||
if e.metadata.ProxyRequestID != "" {
|
||||
requestID, err := json.Marshal(e.metadata.ProxyRequestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object["proxy_request_id"] = requestID
|
||||
}
|
||||
return json.Marshal(object)
|
||||
}
|
||||
|
||||
// WithDiagnosticMetadata attaches optional wire diagnostics to a typed error.
|
||||
// Empty metadata is a no-op. The returned wrapper still participates in
|
||||
// errors.Is/errors.As and TypedError routing through Unwrap and ProblemDetail.
|
||||
func WithDiagnosticMetadata(err error, metadata DiagnosticMetadata) error {
|
||||
if err == nil || (metadata.Origin == "" && metadata.ProxyRequestID == "") {
|
||||
return err
|
||||
}
|
||||
|
||||
typed, ok := UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if existing, ok := diagnosticMetadataWrapperForProducer(typed); ok {
|
||||
merged := existing.metadata
|
||||
if metadata.Origin != "" {
|
||||
merged.Origin = metadata.Origin
|
||||
}
|
||||
if metadata.ProxyRequestID != "" {
|
||||
merged.ProxyRequestID = metadata.ProxyRequestID
|
||||
}
|
||||
return &diagnosticMetadataWrapper{err: err, typed: existing.typed, metadata: merged}
|
||||
}
|
||||
return &diagnosticMetadataWrapper{err: err, typed: typed, metadata: metadata}
|
||||
}
|
||||
|
||||
// DiagnosticMetadataOf returns optional diagnostics attached to the first
|
||||
// typed producer in err's wrap chain. Metadata on a typed cause belongs to
|
||||
// that inner producer and must not be projected onto an outer typed error.
|
||||
func DiagnosticMetadataOf(err error) (DiagnosticMetadata, bool) {
|
||||
typed, ok := UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return DiagnosticMetadata{}, false
|
||||
}
|
||||
carrier, ok := diagnosticMetadataWrapperForProducer(typed)
|
||||
if !ok {
|
||||
return DiagnosticMetadata{}, false
|
||||
}
|
||||
metadata := carrier.DiagnosticMetadata()
|
||||
if metadata.Origin == "" && metadata.ProxyRequestID == "" {
|
||||
return DiagnosticMetadata{}, false
|
||||
}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
// diagnosticMetadataWrapperForProducer deliberately checks only the selected
|
||||
// typed producer. errors.As must not be used here because it would traverse
|
||||
// into an inner typed cause and associate that cause's metadata with the outer
|
||||
// producer.
|
||||
func diagnosticMetadataWrapperForProducer(typed error) (*diagnosticMetadataWrapper, bool) {
|
||||
wrapper, ok := typed.(*diagnosticMetadataWrapper) //nolint:errorlint // Exact producer identity is the invariant being enforced.
|
||||
return wrapper, ok
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiagnosticMetadataPreservesTypedErrorContract(t *testing.T) {
|
||||
permission := NewPermissionError(SubtypeMissingScope, "missing scope").
|
||||
WithMissingScopes("im:message")
|
||||
wrapped := WithDiagnosticMetadata(permission, DiagnosticMetadata{
|
||||
Origin: "proxy",
|
||||
ProxyRequestID: "proxy_req_1",
|
||||
})
|
||||
|
||||
var gotPermission *PermissionError
|
||||
if !errors.As(wrapped, &gotPermission) || gotPermission != permission {
|
||||
t.Fatalf("errors.As() = %p, want original permission error %p", gotPermission, permission)
|
||||
}
|
||||
problem, ok := ProblemOf(wrapped)
|
||||
if !ok || problem != &permission.Problem {
|
||||
t.Fatalf("ProblemOf() = (%p, %v), want original Problem %p", problem, ok, &permission.Problem)
|
||||
}
|
||||
metadata, ok := DiagnosticMetadataOf(wrapped)
|
||||
if !ok || metadata.Origin != "proxy" || metadata.ProxyRequestID != "proxy_req_1" {
|
||||
t.Fatalf("DiagnosticMetadataOf() = (%#v, %v)", metadata, ok)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(wrapped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if object["origin"] != "proxy" || object["proxy_request_id"] != "proxy_req_1" {
|
||||
t.Fatalf("metadata missing from JSON: %s", raw)
|
||||
}
|
||||
missingScopes, ok := object["missing_scopes"].([]any)
|
||||
if !ok || len(missingScopes) != 1 || missingScopes[0] != "im:message" {
|
||||
t.Fatalf("typed extension fields missing from JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticMetadataMergesWithoutMutatingExistingWrapper(t *testing.T) {
|
||||
typed := NewNetworkError(SubtypeUpstreamUnavailable, "unavailable")
|
||||
withOrigin := WithDiagnosticMetadata(typed, DiagnosticMetadata{Origin: "proxy"})
|
||||
withRequestID := WithDiagnosticMetadata(withOrigin, DiagnosticMetadata{ProxyRequestID: "proxy_req_2"})
|
||||
|
||||
original, _ := DiagnosticMetadataOf(withOrigin)
|
||||
if original.ProxyRequestID != "" {
|
||||
t.Fatalf("existing wrapper was mutated: %#v", original)
|
||||
}
|
||||
merged, ok := DiagnosticMetadataOf(withRequestID)
|
||||
if !ok || merged.Origin != "proxy" || merged.ProxyRequestID != "proxy_req_2" {
|
||||
t.Fatalf("merged metadata = (%#v, %v)", merged, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticMetadataPreservesOuterErrorContext(t *testing.T) {
|
||||
cause := errors.New("transport failed")
|
||||
typed := NewNetworkError(SubtypeNetworkTransport, "request failed").WithCause(cause)
|
||||
outer := fmt.Errorf("fetch document: %w", typed)
|
||||
|
||||
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "proxy"})
|
||||
if got, want := wrapped.Error(), outer.Error(); got != want {
|
||||
t.Fatalf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
if !errors.Is(wrapped, cause) {
|
||||
t.Fatal("metadata wrapper lost the original cause chain")
|
||||
}
|
||||
var gotTyped *NetworkError
|
||||
if !errors.As(wrapped, &gotTyped) || gotTyped != typed {
|
||||
t.Fatalf("errors.As() = %p, want original typed error %p", gotTyped, typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticMetadataDoesNotCrossTypedProducerBoundary(t *testing.T) {
|
||||
inner := NewNetworkError(SubtypeUpstreamUnavailable, "proxy unavailable")
|
||||
annotatedInner := WithDiagnosticMetadata(inner, DiagnosticMetadata{
|
||||
Origin: "proxy",
|
||||
ProxyRequestID: "proxy_req_inner",
|
||||
})
|
||||
outer := NewInternalError(SubtypeUnknown, "business reclassified failure").
|
||||
WithCause(annotatedInner)
|
||||
|
||||
if metadata, ok := DiagnosticMetadataOf(outer); ok {
|
||||
t.Fatalf("outer typed producer inherited inner metadata: %#v", metadata)
|
||||
}
|
||||
|
||||
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "cli"})
|
||||
problem, ok := ProblemOf(wrapped)
|
||||
if !ok || problem != &outer.Problem {
|
||||
t.Fatalf("ProblemOf() = (%p, %v), want outer Problem %p", problem, ok, &outer.Problem)
|
||||
}
|
||||
if problem.Category != CategoryInternal ||
|
||||
problem.Subtype != SubtypeUnknown ||
|
||||
problem.Message != "business reclassified failure" {
|
||||
t.Fatalf("outer typed identity changed: %#v", problem)
|
||||
}
|
||||
metadata, ok := DiagnosticMetadataOf(wrapped)
|
||||
if !ok || metadata.Origin != "cli" || metadata.ProxyRequestID != "" {
|
||||
t.Fatalf("outer metadata = (%#v, %v), want cli without inner request id", metadata, ok)
|
||||
}
|
||||
|
||||
innerMetadata, ok := DiagnosticMetadataOf(annotatedInner)
|
||||
if !ok ||
|
||||
innerMetadata.Origin != "proxy" ||
|
||||
innerMetadata.ProxyRequestID != "proxy_req_inner" {
|
||||
t.Fatalf("inner metadata was mutated: (%#v, %v)", innerMetadata, ok)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user