mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
3 Commits
feat/exter
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15263efe30 | ||
|
|
5b67085b32 | ||
|
|
da149e66ba |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,7 +1,4 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
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
|
||||
|
||||
204
.github/workflows/release.yml
vendored
204
.github/workflows/release.yml
vendored
@@ -9,45 +9,10 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
goreleaser:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -61,176 +26,35 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Build and upload draft release with GoReleaser
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
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/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- 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",
|
||||
});
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: build-release
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,16 +25,19 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -250,16 +253,19 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
@@ -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
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -2,120 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
@@ -1722,11 +1608,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
|
||||
11
Makefile
11
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,9 +50,8 @@ 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
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
@@ -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.
|
||||
|
||||
23
README.md
23
README.md
@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -23,41 +23,6 @@ lark-cli contact +search-user --query "alice" --as user
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
39
cmd/build.go
39
cmd/build.go
@@ -29,7 +29,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 +45,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 +58,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 +143,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 +168,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 +220,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,21 @@ 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(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@ 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)
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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,10 +58,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -142,53 +135,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)
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,7 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
||||
Identity: "user",
|
||||
ConsoleURL: "https://example",
|
||||
}
|
||||
withMetadata := WithDiagnosticMetadata(pe, DiagnosticMetadata{
|
||||
Origin: "proxy",
|
||||
ProxyRequestID: "proxy_req_123",
|
||||
})
|
||||
b, err := json.Marshal(withMetadata)
|
||||
b, err := json.Marshal(pe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -43,8 +39,6 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
||||
`"message":"x"`,
|
||||
`"hint":"y"`,
|
||||
`"log_id":"lg"`,
|
||||
`"origin":"proxy"`,
|
||||
`"proxy_request_id":"proxy_req_123"`,
|
||||
`"missing_scopes":["docx:document"]`,
|
||||
`"identity":"user"`,
|
||||
`"console_url":"https://example"`,
|
||||
|
||||
@@ -48,13 +48,11 @@ const (
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
const (
|
||||
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
|
||||
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
|
||||
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
|
||||
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
|
||||
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
|
||||
SubtypeCredentialSourceUnavailable Subtype = "credential_source_unavailable" // external credential program or identity service is temporarily unavailable
|
||||
SubtypeUpstreamUnavailable Subtype = "upstream_unavailable" // external proxy cannot reach the requested upstream service
|
||||
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
|
||||
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
|
||||
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
|
||||
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
|
||||
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
|
||||
)
|
||||
|
||||
// CategoryAPI subtypes
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package build
|
||||
|
||||
const Edition = "extended"
|
||||
|
||||
func Capabilities() []string {
|
||||
return []string{"external-credential-platform"}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package build
|
||||
|
||||
const Edition = "standard"
|
||||
|
||||
func Capabilities() []string { return []string{} }
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/requestcontext"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -120,7 +119,6 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
|
||||
// elsewhere) flow through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ctx = requestcontext.WithIdentity(ctx, as)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
@@ -157,7 +155,6 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
|
||||
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
|
||||
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
|
||||
ctx = requestcontext.WithIdentity(ctx, as)
|
||||
cfg := buildConfig(opts)
|
||||
|
||||
// Resolve auth
|
||||
@@ -215,9 +212,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/requestcontext"
|
||||
)
|
||||
|
||||
// RemoteFile is a service-returned file reference that has passed the active
|
||||
// runtime's data-plane policy. Its URL is intentionally private so callers
|
||||
// cannot construct a trusted reference without Validate.
|
||||
type RemoteFile struct {
|
||||
rawURL string
|
||||
}
|
||||
|
||||
// URL returns the original service-provided URL verbatim.
|
||||
func (f RemoteFile) URL() string { return f.rawURL }
|
||||
|
||||
// NewRequest constructs a request that remains bound to this validated file
|
||||
// reference. Business shortcuts do not construct raw URL requests themselves.
|
||||
func (f RemoteFile) NewRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
|
||||
if f.rawURL == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"cannot build a request for an empty remote file reference")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, f.rawURL, body)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"build remote file request: %v", err).WithCause(err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// DirectRemoteFileValidator applies a caller-specific policy to direct-mode
|
||||
// URLs. Proxy handles are validated by the managed data-plane policy instead.
|
||||
type DirectRemoteFileValidator func(context.Context, string) error
|
||||
|
||||
// HTTPClientProvider resolves the runtime's configured HTTP client without
|
||||
// exposing raw client construction to business shortcuts.
|
||||
type HTTPClientProvider func() (*http.Client, error)
|
||||
|
||||
// RemoteFilePolicy is the source-neutral runtime contract for validating and
|
||||
// routing service-returned file references.
|
||||
type RemoteFilePolicy interface {
|
||||
ValidateRemoteFile(rawURL string) error
|
||||
UsesManagedFilePlane() bool
|
||||
}
|
||||
|
||||
// RemoteFiles is the runtime boundary for service-returned file references.
|
||||
// It keeps credential mode and edition routing out of business shortcuts.
|
||||
// Every service-returned file byte transfer must pass through Validate,
|
||||
// RemoteFile.NewRequest, and Do so the active data-plane policy is enforced.
|
||||
type RemoteFiles struct {
|
||||
policy RemoteFilePolicy
|
||||
managedClient HTTPClientProvider
|
||||
identity core.Identity
|
||||
}
|
||||
|
||||
// NewRemoteFiles creates a file boundary for one resolved runtime identity.
|
||||
func NewRemoteFiles(
|
||||
policy RemoteFilePolicy,
|
||||
managedClient HTTPClientProvider,
|
||||
identity core.Identity,
|
||||
) *RemoteFiles {
|
||||
return &RemoteFiles{
|
||||
policy: policy,
|
||||
managedClient: managedClient,
|
||||
identity: identity,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks a known service-returned file URL and returns an opaque,
|
||||
// typed reference. A directValidator, when supplied, is applied only to the
|
||||
// ordinary/direct data plane; proxy handles use the configured proxy policy.
|
||||
func (r *RemoteFiles) Validate(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
directValidator ...DirectRemoteFileValidator,
|
||||
) (RemoteFile, error) {
|
||||
if r == nil {
|
||||
return RemoteFile{}, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
|
||||
}
|
||||
if r.policy != nil {
|
||||
if err := r.policy.ValidateRemoteFile(rawURL); err != nil {
|
||||
return RemoteFile{}, err
|
||||
}
|
||||
}
|
||||
if !usesManagedFilePlane(r.policy) && len(directValidator) > 0 && directValidator[0] != nil {
|
||||
if err := directValidator[0](ctx, rawURL); err != nil {
|
||||
return RemoteFile{}, err
|
||||
}
|
||||
}
|
||||
return RemoteFile{rawURL: rawURL}, nil
|
||||
}
|
||||
|
||||
// RequirePortableURL rejects commands that would return a managed file handle
|
||||
// for use outside this CLI. The check is capability-based, so callers do not
|
||||
// branch on a credential mode.
|
||||
func (r *RemoteFiles) RequirePortableURL(param string) error {
|
||||
if r == nil || !usesManagedFilePlane(r.policy) {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"%s is unavailable because this credential source requires CLI-managed file routing", param).
|
||||
WithParam(param).
|
||||
WithHint("omit %s and let lark-cli transfer the file", param)
|
||||
}
|
||||
|
||||
// Do executes a request for a validated remote file. Ordinary/direct runtimes
|
||||
// use directClient unchanged; a nil directClient gets the same isolated
|
||||
// DefaultTransport client historically used by presigned Apps transfers.
|
||||
// Managed runtimes select the proxy-aware client and copy the caller's
|
||||
// timeout/redirect policy so existing transfer semantics remain intact.
|
||||
func (r *RemoteFiles) Do(req *http.Request, file RemoteFile, directClient *http.Client) (*http.Response, error) {
|
||||
if r == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
|
||||
}
|
||||
if req == nil || req.URL == nil || file.rawURL == "" || req.URL.String() != file.rawURL {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"remote file request does not match its validated reference")
|
||||
}
|
||||
|
||||
if directClient == nil {
|
||||
directClient = &http.Client{Transport: http.DefaultTransport}
|
||||
}
|
||||
selected := directClient
|
||||
managedPlane := usesManagedFilePlane(r.policy)
|
||||
if managedPlane {
|
||||
if r.managedClient == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"managed remote file runtime has no HTTP client")
|
||||
}
|
||||
managed, err := r.managedClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if managed == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"managed remote file runtime returned no HTTP client")
|
||||
}
|
||||
if directClient != nil && managed != directClient {
|
||||
cloned := *managed
|
||||
cloned.Timeout = directClient.Timeout
|
||||
if directClient.CheckRedirect != nil {
|
||||
cloned.CheckRedirect = directClient.CheckRedirect
|
||||
}
|
||||
selected = &cloned
|
||||
} else {
|
||||
selected = managed
|
||||
}
|
||||
}
|
||||
if !managedPlane {
|
||||
return selected.Do(req)
|
||||
}
|
||||
|
||||
requestCtx := requestcontext.WithIdentity(req.Context(), r.identity)
|
||||
return selected.Do(req.Clone(requestCtx))
|
||||
}
|
||||
|
||||
func usesManagedFilePlane(policy RemoteFilePolicy) bool {
|
||||
return policy != nil && policy.UsesManagedFilePlane()
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const externalCredentialImplementation = "github.com/larksuite/cli/internal/externalcredential"
|
||||
|
||||
// These functions own protocols that are explicitly outside the
|
||||
// service-returned file plane: user-supplied external downloads and MCP.
|
||||
// Adding a boundary is an architectural decision; //nolint alone must never
|
||||
// make a new raw HTTP path possible.
|
||||
var shortcutRawHTTPBoundaries = map[string]string{
|
||||
"common/mcp_client.go:DoMCPCall": "MCP protocol client",
|
||||
"doc/doc_resource_cover.go:downloadDocCoverURL": "validated user-supplied cover URL",
|
||||
"doc/doc_resource_cover.go:newDocCoverHTTPClient": "guarded external-download client",
|
||||
"doc/doc_resource_cover.go:cloneDocCoverTransport": "guarded external-download transport",
|
||||
"im/helpers.go:startURLDownload": "validated user-supplied media URL",
|
||||
}
|
||||
|
||||
func TestCoreDoesNotOwnExternalCredentialProductModel(t *testing.T) {
|
||||
checkCoreDirectoryIsProductNeutral(t, filepath.Join("..", "core"))
|
||||
}
|
||||
|
||||
func TestRuntimeFrameworkDoesNotImportExternalCredentialImplementation(t *testing.T) {
|
||||
for _, dir := range []string{
|
||||
".",
|
||||
filepath.Join("..", "cmdutil"),
|
||||
filepath.Join("..", "credential"),
|
||||
} {
|
||||
checkDirectoryDoesNotImportExternalCredential(t, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func checkCoreDirectoryIsProductNeutral(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
forbidden := []string{
|
||||
"externalcredential",
|
||||
"external-credential",
|
||||
"credential_proxy",
|
||||
"platform_proxy",
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
checkCoreDirectoryIsProductNeutral(t, path)
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
src, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalizedSource := strings.ToLower(string(src))
|
||||
for _, productSymbol := range forbidden {
|
||||
if strings.Contains(normalizedSource, strings.ToLower(productSymbol)) {
|
||||
t.Errorf("%s contains external credential product symbol %q; keep the core profile model product-neutral", path, productSymbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessShortcutsUseRuntimeCredentialBoundaries(t *testing.T) {
|
||||
checkBusinessShortcutDirectory(t, filepath.Join("..", "..", "shortcuts"))
|
||||
}
|
||||
|
||||
func TestShortcutRawHTTPConstructionIsConfinedToExplicitBoundaries(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "shortcuts")
|
||||
checkShortcutRawHTTPDirectory(t, root, root)
|
||||
}
|
||||
|
||||
func TestRawHTTPConstructionGuardRecognizesAliasedBypasses(t *testing.T) {
|
||||
const source = `package fixture
|
||||
import nethttp "net/http"
|
||||
func bypass() {
|
||||
_ = &nethttp.Client{}
|
||||
_, _ = nethttp.NewRequest(nethttp.MethodGet, "https://files.example", nil)
|
||||
_ = nethttp.DefaultClient
|
||||
}`
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uses := rawHTTPConstructions(file, map[string]struct{}{"nethttp": {}})
|
||||
joined := strings.Join(uses, ",")
|
||||
for _, want := range []string{"Client literal", "NewRequest", "DefaultClient"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("raw HTTP uses = %q, want %q", joined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkDirectoryDoesNotImportExternalCredential(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
checkDirectoryDoesNotImportExternalCredential(t, path)
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, src, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
for _, spec := range file.Imports {
|
||||
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse import in %s: %v", path, err)
|
||||
}
|
||||
if importPath == externalCredentialImplementation ||
|
||||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
|
||||
t.Errorf("%s imports external credential implementation; inject a source-neutral runtime boundary", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkBusinessShortcutDirectory(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if entry.Name() != "common" {
|
||||
checkBusinessShortcutDirectory(t, path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
for _, spec := range file.Imports {
|
||||
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse import in %s: %v", path, err)
|
||||
}
|
||||
if importPath == externalCredentialImplementation ||
|
||||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
|
||||
t.Errorf("%s imports external credential implementation; use a source-neutral runtime capability boundary", path)
|
||||
}
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
selector, ok := node.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "ExternalCredential" {
|
||||
t.Errorf("%s selects ExternalCredential directly; use a runtime capability boundary", path)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkShortcutRawHTTPDirectory(t *testing.T, root, dir string) {
|
||||
t.Helper()
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
checkShortcutRawHTTPDirectory(t, root, path)
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
httpAliases := make(map[string]struct{})
|
||||
for _, spec := range file.Imports {
|
||||
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse import in %s: %v", path, err)
|
||||
}
|
||||
if importPath != "net/http" {
|
||||
continue
|
||||
}
|
||||
alias := "http"
|
||||
if spec.Name != nil {
|
||||
alias = spec.Name.Name
|
||||
}
|
||||
if alias == "." {
|
||||
t.Errorf("%s dot-imports net/http; raw HTTP boundaries must remain statically auditable", path)
|
||||
continue
|
||||
}
|
||||
if alias != "_" {
|
||||
httpAliases[alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(httpAliases) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
t.Fatalf("relative shortcut path for %s: %v", path, err)
|
||||
}
|
||||
relativePath = filepath.ToSlash(relativePath)
|
||||
for _, decl := range file.Decls {
|
||||
fn, isFunc := decl.(*ast.FuncDecl)
|
||||
uses := rawHTTPConstructions(decl, httpAliases)
|
||||
if len(uses) == 0 {
|
||||
continue
|
||||
}
|
||||
if !isFunc {
|
||||
t.Errorf("%s constructs raw net/http at package scope (%s); route service-returned file bytes through RuntimeContext.RemoteFiles",
|
||||
path, strings.Join(uses, ", "))
|
||||
continue
|
||||
}
|
||||
boundary := relativePath + ":" + fn.Name.Name
|
||||
if _, allowed := shortcutRawHTTPBoundaries[boundary]; allowed {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s function %s constructs raw net/http (%s); route service-returned file bytes through RuntimeContext.RemoteFiles or declare a reviewed non-file-plane boundary",
|
||||
path, fn.Name.Name, strings.Join(uses, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawHTTPConstructions(node ast.Node, aliases map[string]struct{}) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var uses []string
|
||||
add := func(name string) {
|
||||
if _, ok := seen[name]; ok {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
uses = append(uses, name)
|
||||
}
|
||||
isHTTPSelector := func(selector *ast.SelectorExpr) bool {
|
||||
ident, ok := selector.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = aliases[ident.Name]
|
||||
return ok
|
||||
}
|
||||
|
||||
ast.Inspect(node, func(current ast.Node) bool {
|
||||
switch typed := current.(type) {
|
||||
case *ast.CallExpr:
|
||||
selector, ok := typed.Fun.(*ast.SelectorExpr)
|
||||
if !ok || !isHTTPSelector(selector) {
|
||||
break
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "NewRequest", "NewRequestWithContext", "Get", "Post", "PostForm", "Head", "Serve", "ListenAndServe":
|
||||
add(selector.Sel.Name)
|
||||
}
|
||||
case *ast.CompositeLit:
|
||||
selector, ok := typed.Type.(*ast.SelectorExpr)
|
||||
if !ok || !isHTTPSelector(selector) {
|
||||
break
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "Client", "Request", "Transport":
|
||||
add(selector.Sel.Name + " literal")
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
if !isHTTPSelector(typed) {
|
||||
break
|
||||
}
|
||||
switch typed.Sel.Name {
|
||||
case "DefaultClient", "DefaultTransport":
|
||||
add(typed.Sel.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return uses
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/requestcontext"
|
||||
)
|
||||
|
||||
type testRemoteFilePolicy struct {
|
||||
allowed string
|
||||
managed bool
|
||||
}
|
||||
|
||||
func (p testRemoteFilePolicy) ValidateRemoteFile(rawURL string) error {
|
||||
if p.allowed != "" && rawURL != p.allowed {
|
||||
return errors.New("remote file rejected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p testRemoteFilePolicy) UsesManagedFilePlane() bool { return p.managed }
|
||||
|
||||
func TestRemoteFilesDirectPreservesCallerClientAndPolicy(t *testing.T) {
|
||||
var managedCalls, directCalls, validationCalls int
|
||||
direct := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
directCalls++
|
||||
if got := requestcontext.Identity(req.Context()); got != "" {
|
||||
t.Fatalf("direct request context was changed: identity = %q", got)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
files := NewRemoteFiles(nil, func() (*http.Client, error) {
|
||||
managedCalls++
|
||||
return nil, errors.New("must not be called")
|
||||
}, core.AsUser)
|
||||
|
||||
file, err := files.Validate(context.Background(), "https://files.example/object?signature=x",
|
||||
func(_ context.Context, rawURL string) error {
|
||||
validationCalls++
|
||||
if rawURL != "https://files.example/object?signature=x" {
|
||||
t.Fatalf("validator URL = %q", rawURL)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := files.Do(req, file, direct)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if directCalls != 1 || managedCalls != 0 || validationCalls != 1 {
|
||||
t.Fatalf("calls direct=%d managed=%d validation=%d", directCalls, managedCalls, validationCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFilesManagedValidatesAndRoutesOpaqueHandle(t *testing.T) {
|
||||
const opaqueURL = "https://proxy.example/lark-cli/v1/files/opaque_1"
|
||||
policy := testRemoteFilePolicy{allowed: opaqueURL, managed: true}
|
||||
var managedCalls, directCalls, directValidationCalls int
|
||||
managed := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
managedCalls++
|
||||
if got := requestcontext.Identity(req.Context()); got != core.AsBot {
|
||||
t.Fatalf("request identity = %q, want bot", got)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
direct := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
directCalls++
|
||||
return nil, errors.New("must not be called")
|
||||
})}
|
||||
files := NewRemoteFiles(policy, func() (*http.Client, error) { return managed, nil }, core.AsBot)
|
||||
|
||||
if _, err := files.Validate(context.Background(), "https://objects.example/raw?signature=secret"); err == nil {
|
||||
t.Fatal("raw object URL accepted in managed mode")
|
||||
}
|
||||
file, err := files.Validate(context.Background(), opaqueURL,
|
||||
func(context.Context, string) error {
|
||||
directValidationCalls++
|
||||
return errors.New("must not be called")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := files.Do(req, file, direct)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if managedCalls != 1 || directCalls != 0 || directValidationCalls != 0 {
|
||||
t.Fatalf("calls managed=%d direct=%d validation=%d", managedCalls, directCalls, directValidationCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFilesDoRejectsReferenceMismatch(t *testing.T) {
|
||||
files := NewRemoteFiles(nil, nil, core.AsUser)
|
||||
file, err := files.Validate(context.Background(), "https://files.example/one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://files.example/two", nil)
|
||||
if _, err := files.Do(req, file, http.DefaultClient); err == nil {
|
||||
t.Fatal("mismatched request and validated file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFilesRequirePortableURL(t *testing.T) {
|
||||
files := NewRemoteFiles(testRemoteFilePolicy{managed: true}, nil, core.AsBot)
|
||||
err := files.RequirePortableURL("--url-only")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Param != "--url-only" {
|
||||
t.Fatalf("error = subtype %q param %q", validationErr.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAPIPreservesURLsInArbitraryJSON(t *testing.T) {
|
||||
const rawURL = "https://objects.example/raw?signature=opaque"
|
||||
apiClient, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
resp := jsonResponse(map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"download_url": rawURL,
|
||||
"nested": []any{map[string]any{"url": rawURL}},
|
||||
},
|
||||
})
|
||||
resp.Request = req
|
||||
return resp, nil
|
||||
}))
|
||||
result, err := apiClient.CallAPI(context.Background(), RawApiRequest{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/example/v1/raw",
|
||||
As: core.AsBot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := result.(map[string]any)["data"].(map[string]any)
|
||||
if got := data["download_url"]; got != rawURL {
|
||||
t.Fatalf("download_url = %v, want exact arbitrary JSON value %q", got, rawURL)
|
||||
}
|
||||
nested := data["nested"].([]any)[0].(map[string]any)
|
||||
if got := nested["url"]; got != rawURL {
|
||||
t.Fatalf("nested url = %v, want exact arbitrary JSON value %q", got, rawURL)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -44,8 +43,6 @@ type Factory struct {
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
runtimePlan *runtimeplan.Plan
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
@@ -60,34 +57,6 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
return f.FileIOProvider.ResolveFileIO(ctx)
|
||||
}
|
||||
|
||||
// NewRemoteFiles returns the invocation's file-transfer boundary without
|
||||
// exposing credential source, mode, or edition to business shortcuts.
|
||||
func (f *Factory) NewRemoteFiles(identity core.Identity) *client.RemoteFiles {
|
||||
if f == nil {
|
||||
return client.NewRemoteFiles(nil, nil, identity)
|
||||
}
|
||||
return client.NewRemoteFiles(runtimeplan.Ensure(f.runtimePlan), f.HttpClient, identity)
|
||||
}
|
||||
|
||||
// RuntimeDescription returns sanitized diagnostics for the active plan.
|
||||
func (f *Factory) RuntimeDescription() runtimeplan.Description {
|
||||
if f == nil {
|
||||
return runtimeplan.Description{}
|
||||
}
|
||||
return runtimeplan.Ensure(f.runtimePlan).Describe()
|
||||
}
|
||||
|
||||
// RuntimeStartupError reports whether invocation bootstrap failed before an
|
||||
// effective credential/runtime configuration could be selected. It exposes
|
||||
// only the source-neutral plan contract so diagnostic commands do not need to
|
||||
// know which edition or credential product produced the failure.
|
||||
func (f *Factory) RuntimeStartupError() error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return runtimeplan.Ensure(f.runtimePlan).StartupError()
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
// If the user explicitly passed --as, use that value; otherwise use the configured default.
|
||||
// When the value is "auto" (or unset), auto-detect based on credential hints.
|
||||
@@ -241,3 +210,26 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
|
||||
Credential: f.Credential,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RequireBuiltinCredentialProvider returns a typed validation error when an
|
||||
// extension provider is actively managing credentials. Intended for use as
|
||||
// PersistentPreRunE on the auth and config parent commands.
|
||||
//
|
||||
// Returns nil when:
|
||||
// - f.Credential is nil (test environments without credential setup)
|
||||
// - No extension provider is active (built-in keychain/config path is used)
|
||||
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if provName == "" {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%q is not supported: credentials are provided externally and do not support interactive management", command).
|
||||
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
|
||||
}
|
||||
|
||||
@@ -22,105 +22,64 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
var (
|
||||
initRegistryWithBrand = registry.InitWithBrand
|
||||
initEmbeddedRegistryWithBrand = registry.InitEmbeddedWithBrand
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
// Initialization follows a credential-first order:
|
||||
//
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential and workspace policy
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// Preserve the established standalone Factory behavior. Product-specific
|
||||
// runtime selection belongs to the CLI composition root, which calls
|
||||
// NewDefaultWithRuntimePlan with one immutable startup snapshot.
|
||||
core.SetCurrentWorkspace(core.DetectWorkspaceFromEnv(os.Getenv))
|
||||
return newDefaultWithRuntimePlan(streams, inv, nil, runtimeplan.Default(), false)
|
||||
}
|
||||
|
||||
// NewDefaultWithRuntimePlan creates a production Factory from the same
|
||||
// immutable Profile snapshot and source-neutral plan used by startup routing.
|
||||
func NewDefaultWithRuntimePlan(
|
||||
streams *IOStreams,
|
||||
inv InvocationContext,
|
||||
profileConfig *core.MultiAppConfig,
|
||||
plan *runtimeplan.Plan,
|
||||
) *Factory {
|
||||
return newDefaultWithRuntimePlan(streams, inv, profileConfig, plan, true)
|
||||
}
|
||||
|
||||
func newDefaultWithRuntimePlan(
|
||||
streams *IOStreams,
|
||||
inv InvocationContext,
|
||||
profileConfig *core.MultiAppConfig,
|
||||
plan *runtimeplan.Plan,
|
||||
useProfileSnapshot bool,
|
||||
) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
IOStreams: streams,
|
||||
runtimePlan: runtimeplan.Ensure(plan),
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
IOStreams: streams,
|
||||
}
|
||||
|
||||
// Workspace detection: determines which config subtree to use.
|
||||
// Must run before any config or credential load, since those paths are
|
||||
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(ws)
|
||||
|
||||
// Inject workspace-aware dir into keychain's log system.
|
||||
// This breaks the core↔keychain import cycle by using a function variable.
|
||||
keychain.RuntimeDirFunc = core.GetRuntimeDir
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
if profileConfig != nil {
|
||||
workspaceConfig = core.NewConfigSnapshotFrom(profileConfig)
|
||||
}
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
RuntimePlan: f.runtimePlan,
|
||||
ProfileConfigSnapshot: profileConfig,
|
||||
UseProfileSnapshot: useProfileSnapshot,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := acct.ToCliConfig()
|
||||
if f.runtimePlan.AllowsRemoteMetadata() {
|
||||
initRegistryWithBrand(cfg.Brand)
|
||||
} else {
|
||||
// Defense in depth for callers that construct a Factory directly
|
||||
// instead of going through cmd/build's composition root.
|
||||
initEmbeddedRegistryWithBrand(cfg.Brand)
|
||||
}
|
||||
registry.InitWithBrand(cfg.Brand)
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||
// transport boundary.
|
||||
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -149,23 +108,13 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
var err error
|
||||
rt, err = applyRuntimePlan(f, rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Risk control remains the final trusted header boundary before either
|
||||
// the ordinary network transport or the managed proxy data plane.
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
@@ -179,7 +128,7 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -193,20 +142,8 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
sdkBase, err = applyRuntimePlan(f, sdkBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection. A managed runtime applies its data-plane policy after this
|
||||
// boundary so trusted signals remain associated with the original request.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: sdkTransport,
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -215,56 +152,26 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
sdkTransport = wrapWithExtension(sdkTransport)
|
||||
return sdkTransport
|
||||
}
|
||||
|
||||
func applyRuntimePlan(f *Factory, base http.RoundTripper) (http.RoundTripper, error) {
|
||||
if f == nil {
|
||||
return base, nil
|
||||
}
|
||||
return runtimeplan.Ensure(f.runtimePlan).Wrap(base)
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
RuntimePlan *runtimeplan.Plan
|
||||
ProfileConfigSnapshot *core.MultiAppConfig
|
||||
UseProfileSnapshot bool
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
plan := runtimeplan.Ensure(deps.RuntimePlan)
|
||||
providers := extcred.Providers()
|
||||
localAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
|
||||
if deps.UseProfileSnapshot {
|
||||
localAcct = credential.NewDefaultAccountProviderFromSnapshot(deps.Keychain, deps.Profile, deps.ProfileConfigSnapshot)
|
||||
}
|
||||
localToken := credential.NewDefaultTokenProvider(localAcct, deps.HttpClient, deps.ErrOut)
|
||||
var defaultAcct credential.DefaultAccountResolver = localAcct
|
||||
var defaultToken credential.DefaultTokenResolver = localToken
|
||||
|
||||
if startupErr := plan.StartupError(); startupErr != nil {
|
||||
providers = []extcred.Provider{&runtimePlanErrorProvider{err: startupErr}}
|
||||
defaultAcct = nil
|
||||
defaultToken = nil
|
||||
} else if provider, replace := plan.CredentialProvider(); provider != nil {
|
||||
if replace {
|
||||
providers = []extcred.Provider{provider}
|
||||
defaultAcct = nil
|
||||
defaultToken = nil
|
||||
} else {
|
||||
providers = append([]extcred.Provider{provider}, providers...)
|
||||
}
|
||||
}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
|
||||
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
|
||||
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
|
||||
// happens before the command runs, so any plain-text warning written
|
||||
// to stderr would break the JSON envelope contract that AI agents
|
||||
@@ -273,15 +180,3 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
||||
// warning is safe.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
}
|
||||
|
||||
type runtimePlanErrorProvider struct{ err error }
|
||||
|
||||
func (p *runtimePlanErrorProvider) Name() string { return "runtime-policy" }
|
||||
|
||||
func (p *runtimePlanErrorProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return nil, p.err
|
||||
}
|
||||
|
||||
func (p *runtimePlanErrorProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, p.err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -15,9 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/runtimebootstrap"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
@@ -217,63 +213,3 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePlanMetadataPolicyIsEnforcedByFactory(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
|
||||
originalRemote := initRegistryWithBrand
|
||||
originalEmbedded := initEmbeddedRegistryWithBrand
|
||||
t.Cleanup(func() {
|
||||
initRegistryWithBrand = originalRemote
|
||||
initEmbeddedRegistryWithBrand = originalEmbedded
|
||||
})
|
||||
|
||||
var remoteCalls, embeddedCalls int
|
||||
initRegistryWithBrand = func(core.LarkBrand) { remoteCalls++ }
|
||||
initEmbeddedRegistryWithBrand = func(core.LarkBrand) { embeddedCalls++ }
|
||||
|
||||
profile := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}}}
|
||||
plan := runtimeplan.New(runtimeplan.Options{Metadata: runtimeplan.MetadataEmbeddedOnly})
|
||||
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, profile, plan)
|
||||
if _, err := f.Config(); err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
if remoteCalls != 0 || embeddedCalls != 1 {
|
||||
t.Fatalf("registry init calls = remote:%d embedded:%d, want remote:0 embedded:1", remoteCalls, embeddedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICompositionPreservesEnvironmentCredentialsWhenProfileIsUnreadable(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliExternalCredentialConfig,
|
||||
filepath.Join(configDir, "missing-external-credential.json"))
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if err := vfs.MkdirAll(core.GetConfigPath(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startup := runtimebootstrap.Resolve("")
|
||||
if err := startup.Plan.StartupError(); err != nil {
|
||||
t.Fatalf("runtime bootstrap changed legacy environment fallback: %v", err)
|
||||
}
|
||||
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, startup.ProfileConfig, startup.Plan)
|
||||
account, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if account.AppID != "cli_env" {
|
||||
t.Fatalf("account AppID = %q, want environment account", account.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build extended
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/externalcredential"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/requestcontext"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
"github.com/larksuite/cli/internal/runtimebootstrap"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
func newFactoryFromRuntimeBootstrap(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
startup := runtimebootstrap.Resolve(inv.Profile)
|
||||
return NewDefaultWithRuntimePlan(streams, inv, startup.ProfileConfig, startup.Plan)
|
||||
}
|
||||
|
||||
func writePlatformProxyConfiguration(t *testing.T, config *core.MultiAppConfig, appID string) {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
systemPath := configDir + "/external-credential.json"
|
||||
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system := externalcredential.Config{
|
||||
Version: 1, Mode: externalcredential.ModePlatformProxy,
|
||||
RemoteEndpoint: "https://credentials.example.com",
|
||||
Applications: []externalcredential.Application{{Brand: core.BrandFeishu, AppID: appID}},
|
||||
}
|
||||
data, err := json.Marshal(system)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(systemPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryInstallsExternalProxyTransport(t *testing.T) {
|
||||
t.Setenv(internaltransport.EnvNoProxy, "")
|
||||
previousTransport := http.DefaultTransport
|
||||
var observed *http.Request
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
observed = req.Clone(req.Context())
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
Name: "sandbox", AppId: "cli_test", Brand: core.BrandFeishu, Lang: i18n.LangJaJP, Users: []core.AppUser{},
|
||||
}}}
|
||||
writePlatformProxyConfiguration(t, config, "cli_test")
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
resolved, err := factory.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Lang != i18n.LangJaJP {
|
||||
t.Fatalf("resolved Lang = %q, want Profile Lang %q", resolved.Lang, i18n.LangJaJP)
|
||||
}
|
||||
client, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := requestcontext.WithIdentity(context.Background(), core.AsUser)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
"https://open.feishu.cn/open-apis/test/v1/ping", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if observed == nil ||
|
||||
observed.URL.String() != "https://credentials.example.com/lark-cli/v1/openapi/open-apis/test/v1/ping" ||
|
||||
observed.Header.Get(externalcredential.HeaderAppID) != "cli_test" ||
|
||||
observed.Header.Get(externalcredential.HeaderIdentity) != "user" ||
|
||||
observed.Header.Get(riskcontrol.HeaderOSType) == "" {
|
||||
t.Fatalf("managed data-plane request = %#v", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsMalformed(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if err := os.WriteFile(core.GetConfigPath(), []byte(`{"apps":[`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
account, err := factory.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if account.AppID != "cli_env" {
|
||||
t.Fatalf("account AppID = %q, want environment account", account.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsUnreadable(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
if err := os.Mkdir(core.GetConfigPath(), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
account, err := factory.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if account.AppID != "cli_env" {
|
||||
t.Fatalf("account AppID = %q, want environment account", account.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRejectsMisspelledExternalCredentialInsteadOfFallingBack(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredentials":{"mode":"proxy"}}]}`
|
||||
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
|
||||
t.Fatal("expected misspelled external credential profile to fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRejectsNullExternalCredentialInsteadOfFallingBack(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredential":null}]}`
|
||||
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
|
||||
t.Fatal("expected null external credential profile to fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRejectsMissingSelectedExternalCredentialProfile(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_env")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
|
||||
config := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "sandbox", AppId: "cli_external", Brand: core.BrandFeishu, Users: []core.AppUser{},
|
||||
}},
|
||||
}
|
||||
writePlatformProxyConfiguration(t, config, "cli_external")
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
|
||||
t.Fatal("expected missing selected external credential profile to fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryUsesOneProfileSnapshotForCredentialAndTransport(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
systemPath := configDir + "/external-credential.json"
|
||||
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
|
||||
for _, name := range []string{
|
||||
envvars.CliAppID, envvars.CliAppSecret, envvars.CliUserAccessToken,
|
||||
envvars.CliTenantAccessToken, envvars.CliAuthProxy, envvars.CliProxyKey,
|
||||
envvars.CliProxyEnable, envvars.CliProxyAddress, envvars.CliCAPath,
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
initial := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
Name: "initial", AppId: "cli_initial", AppSecret: core.PlainSecret("initial-secret"),
|
||||
Brand: core.BrandFeishu, Users: []core.AppUser{},
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
replacement := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
Name: "replacement", AppId: "cli_proxy", Brand: core.BrandFeishu, Users: []core.AppUser{},
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(replacement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(systemPath, []byte(`{"version":1,"mode":"platform_proxy","remoteEndpoint":"https://credentials.example.com","applications":[{"brand":"feishu","appId":"cli_proxy"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
account, err := factory.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AppID != "cli_initial" {
|
||||
t.Fatalf("account AppID = %q, want immutable snapshot cli_initial", account.AppID)
|
||||
}
|
||||
client, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := client.Transport.(*externalcredential.Transport); ok {
|
||||
t.Fatalf("transport = %T, should match initial non-proxy profile", client.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRejectsProxyModeWithTransportExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", Brand: core.BrandFeishu, Users: []core.AppUser{},
|
||||
}}}
|
||||
writePlatformProxyConfiguration(t, config, "cli_test")
|
||||
|
||||
factory := newFactoryFromRuntimeBootstrap(&IOStreams{Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
|
||||
if _, err := factory.HttpClient(); err == nil {
|
||||
t.Fatal("expected proxy mode and transport extension to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -34,10 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -45,10 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -37,15 +36,13 @@ var proxyWarnGateCases = []struct {
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
f.IOStreams.StderrIsTerminal = tc.terminal
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
@@ -76,7 +73,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
|
||||
@@ -414,13 +413,13 @@ func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T) {
|
||||
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
|
||||
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -440,44 +439,25 @@ func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilities_DefaultCommandLabel(t *testing.T) {
|
||||
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "", runtimeplan.CapabilityLocalCredentialManagement)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed error", err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilities_AllowsLocalCredentialProvider(t *testing.T) {
|
||||
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
|
||||
// No extension providers → built-in path → no error
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilities_AllowsNilCredential(t *testing.T) {
|
||||
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = nil
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with nil Credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
|
||||
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
|
||||
sentinel := errors.New("provider unavailable")
|
||||
stub := &stubExtProvider{name: "env", err: sentinel}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
@@ -485,7 +465,7 @@ func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("error = %v, want sentinel", err)
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// StatLocalFile returns metadata for a path in the process filesystem namespace.
|
||||
// It is intended for advisory validation; callers must validate the opened file
|
||||
// again before using its contents.
|
||||
func StatLocalFile(path string) (fs.FileInfo, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(localPath)
|
||||
}
|
||||
|
||||
// OpenLocalFile opens a path in the process filesystem namespace.
|
||||
// Absolute and relative paths are accepted. It is the shared replacement for
|
||||
// direct os.Open/os.ReadFile use in commands that intentionally read local
|
||||
// paths outside the workspace sandbox. Callers inspect the returned descriptor
|
||||
// before reading so validation and use apply to the same opened file.
|
||||
func OpenLocalFile(path string) (fs.File, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(localPath)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
TestChdir(t, workDir)
|
||||
|
||||
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
|
||||
f, err := OpenLocalFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
|
||||
}
|
||||
got, readErr := io.ReadAll(f)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil || closeErr != nil || string(got) != "content" {
|
||||
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
|
||||
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
|
||||
info, err := StatLocalFile(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("StatLocalFile() error = %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := vfs.DefaultFS
|
||||
counting := &countingLocalFileFS{FS: previous}
|
||||
vfs.DefaultFS = counting
|
||||
t.Cleanup(func() { vfs.DefaultFS = previous })
|
||||
|
||||
f, err := OpenLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile() error = %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counting.openCalls != 1 || counting.statCalls != 0 {
|
||||
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingLocalFileFS struct {
|
||||
vfs.FS
|
||||
openCalls int
|
||||
statCalls int
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
|
||||
f.openCalls++
|
||||
return f.FS.Open(name)
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
|
||||
f.statCalls++
|
||||
return f.FS.Stat(name)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type workspaceConfigSource interface {
|
||||
MultiAppConfig() (*core.MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
|
||||
// boundary.
|
||||
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, configErr := config.MultiAppConfig()
|
||||
// Default-on means an existing config with no explicit preference. Absent
|
||||
// or unreadable config cannot authorize host-signal collection.
|
||||
if configErr != nil || !workspace.RiskControlEnabled() {
|
||||
return nil
|
||||
}
|
||||
return riskcontrol.NewHostSource()
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type staticWorkspaceConfig struct {
|
||||
config *core.MultiAppConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
func TestResolveSDKHostSignalSource(t *testing.T) {
|
||||
disabled := false
|
||||
tests := []struct {
|
||||
name string
|
||||
config workspaceConfigSource
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
|
||||
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
|
||||
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
|
||||
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
|
||||
{name: "nil config value", config: staticWorkspaceConfig{}},
|
||||
{name: "nil config source"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := resolveSDKHostSignalSource(test.config)
|
||||
if (got != nil) != test.wantSource {
|
||||
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeCapabilitiesAnnotation = "lark:runtimeCapabilities"
|
||||
noRuntimeCapabilities = "-"
|
||||
)
|
||||
|
||||
// SetRuntimeCapabilities declares the runtime surfaces required by a command.
|
||||
// A zero-capability declaration explicitly overrides a parent default.
|
||||
func SetRuntimeCapabilities(cmd *cobra.Command, capabilities ...runtimeplan.Capability) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = make(map[string]string)
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
|
||||
return
|
||||
}
|
||||
values := make([]string, 0, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
if capability != "" {
|
||||
values = append(values, string(capability))
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
|
||||
return
|
||||
}
|
||||
cmd.Annotations[runtimeCapabilitiesAnnotation] = strings.Join(values, ",")
|
||||
}
|
||||
|
||||
// GetRuntimeCapabilities resolves the nearest explicit declaration from the
|
||||
// leaf command toward its parents.
|
||||
func GetRuntimeCapabilities(cmd *cobra.Command) []runtimeplan.Capability {
|
||||
for current := cmd; current != nil; current = current.Parent() {
|
||||
raw, ok := current.Annotations[runtimeCapabilitiesAnnotation]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if raw == "" || raw == noRuntimeCapabilities {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]runtimeplan.Capability, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if capability := strings.TrimSpace(part); capability != "" {
|
||||
out = append(out, runtimeplan.Capability(capability))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireRuntimeCapabilities applies the invocation plan and the selected
|
||||
// credential source to a command's source-neutral capability declaration.
|
||||
func (f *Factory) RequireRuntimeCapabilities(
|
||||
ctx context.Context,
|
||||
command string,
|
||||
capabilities ...runtimeplan.Capability,
|
||||
) error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
plan := runtimeplan.Ensure(f.runtimePlan)
|
||||
for _, capability := range capabilities {
|
||||
if err := plan.Require(capability); err != nil {
|
||||
return err
|
||||
}
|
||||
if capability != runtimeplan.CapabilityLocalCredentialManagement || f.Credential == nil {
|
||||
continue
|
||||
}
|
||||
providerName, err := f.Credential.ActiveExtensionProviderName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if providerName == "" {
|
||||
continue
|
||||
}
|
||||
if command == "" {
|
||||
command = "credential management"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"%q cannot manage credentials owned by provider %q", command, providerName).
|
||||
WithHint("manage authorization through the active credential provider, or use a local Profile credential source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireCommandRuntimeCapabilities checks the declaration resolved for cmd.
|
||||
func (f *Factory) RequireCommandRuntimeCapabilities(ctx context.Context, cmd *cobra.Command) error {
|
||||
command := "credential management"
|
||||
if cmd != nil {
|
||||
command = cmd.CommandPath()
|
||||
}
|
||||
return f.RequireRuntimeCapabilities(ctx, command, GetRuntimeCapabilities(cmd)...)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRuntimeCapabilitiesUseNearestDeclaration(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "auth"}
|
||||
SetRuntimeCapabilities(parent, runtimeplan.CapabilityLocalCredentialManagement)
|
||||
|
||||
inherited := &cobra.Command{Use: "login"}
|
||||
parent.AddCommand(inherited)
|
||||
got := GetRuntimeCapabilities(inherited)
|
||||
if len(got) != 1 || got[0] != runtimeplan.CapabilityLocalCredentialManagement {
|
||||
t.Fatalf("inherited capabilities = %v", got)
|
||||
}
|
||||
|
||||
diagnostic := &cobra.Command{Use: "status"}
|
||||
SetRuntimeCapabilities(diagnostic)
|
||||
parent.AddCommand(diagnostic)
|
||||
if got := GetRuntimeCapabilities(diagnostic); len(got) != 0 {
|
||||
t.Fatalf("explicit empty capabilities = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilitiesUsesPlan(t *testing.T) {
|
||||
denied := errors.New("events denied")
|
||||
plan := runtimeplan.New(runtimeplan.Options{
|
||||
Capabilities: func(capability runtimeplan.Capability) error {
|
||||
if capability == runtimeplan.CapabilityRealtimeEvents {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil, plan)
|
||||
|
||||
err := f.RequireRuntimeCapabilities(context.Background(), "event consume", runtimeplan.CapabilityRealtimeEvents)
|
||||
if !errors.Is(err, denied) {
|
||||
t.Fatalf("error = %v, want plan denial", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilitiesKeepsPurelyLocalCommandsAvailable(t *testing.T) {
|
||||
startupErr := errors.New("managed runtime bootstrap failed")
|
||||
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil,
|
||||
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly))
|
||||
|
||||
if err := f.RequireRuntimeCapabilities(context.Background(), "local recovery"); err != nil {
|
||||
t.Fatalf("capability-free local command = %v, want available", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeCapabilitiesBlocksProviderOwnedCredentials(t *testing.T) {
|
||||
provider := &runtimeCapabilityProvider{}
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{provider}, nil, nil, nil,
|
||||
)
|
||||
|
||||
err := f.RequireRuntimeCapabilities(
|
||||
context.Background(),
|
||||
"auth login",
|
||||
runtimeplan.CapabilityLocalCredentialManagement,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected provider-owned credentials to reject local management")
|
||||
}
|
||||
if err := f.RequireRuntimeCapabilities(
|
||||
context.Background(),
|
||||
"profile use",
|
||||
runtimeplan.CapabilityLocalProfileMutation,
|
||||
); err != nil {
|
||||
t.Fatalf("generic provider unexpectedly blocked Standard Profile mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeCapabilityProvider struct{}
|
||||
|
||||
func (*runtimeCapabilityProvider) Name() string { return "test-provider" }
|
||||
func (*runtimeCapabilityProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "cli_test"}, nil
|
||||
}
|
||||
func (*runtimeCapabilityProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -26,7 +26,6 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -56,9 +55,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,34 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/runtimeplan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -77,29 +76,6 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
|
||||
// TestFactoryWithRuntimePlan creates a TestFactory with an explicit
|
||||
// source-neutral runtime policy.
|
||||
func TestFactoryWithRuntimePlan(
|
||||
t *testing.T,
|
||||
config *core.CliConfig,
|
||||
plan *runtimeplan.Plan,
|
||||
) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, out, errOut, registry := TestFactory(t, config)
|
||||
f.runtimePlan = runtimeplan.Ensure(plan)
|
||||
return f, out, errOut, registry
|
||||
}
|
||||
|
||||
// TestSetRuntimePlan replaces a Factory's runtime policy in tests that need to
|
||||
// preserve an existing HTTP mock registry or other custom wiring.
|
||||
func TestSetRuntimePlan(t *testing.T, f *Factory, plan *runtimeplan.Plan) {
|
||||
t.Helper()
|
||||
if f == nil {
|
||||
t.Fatal("cannot install a runtime plan on a nil Factory")
|
||||
}
|
||||
f.runtimePlan = runtimeplan.Ensure(plan)
|
||||
}
|
||||
|
||||
type testDefaultAcct struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -92,13 +91,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapSDKTransport chain composition
|
||||
// buildSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -111,23 +110,18 @@ func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
@@ -144,23 +138,17 @@ func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -173,13 +161,9 @@ func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -277,40 +261,6 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
|
||||
return nil
|
||||
}
|
||||
|
||||
type riskHeaderTamperingInterceptor struct{}
|
||||
|
||||
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
|
||||
t.Fatalf("extension risk headers reached network: %v", received)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
@@ -327,7 +277,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
|
||||
@@ -60,18 +60,11 @@ func (a *AppConfig) ProfileName() string {
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
RiskControl *bool `json:"riskControl,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// RiskControlEnabled resolves the workspace policy. An omitted preference
|
||||
// keeps the default-on account-protection behavior.
|
||||
func (m *MultiAppConfig) RiskControlEnabled() bool {
|
||||
return m != nil && (m.RiskControl == nil || *m.RiskControl)
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
|
||||
// invocation. All runtime consumers share the same load result so account and
|
||||
// workspace policy resolution cannot observe different file revisions. Callers
|
||||
// must treat the returned config as read-only.
|
||||
type ConfigSnapshot struct {
|
||||
load func() (*MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
|
||||
func NewConfigSnapshot() *ConfigSnapshot {
|
||||
return newConfigSnapshot(LoadMultiAppConfig)
|
||||
}
|
||||
|
||||
// NewConfigSnapshotFrom creates a snapshot from configuration that was
|
||||
// already captured by another invocation-start boundary.
|
||||
func NewConfigSnapshotFrom(config *MultiAppConfig) *ConfigSnapshot {
|
||||
return newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
return config, nil
|
||||
})
|
||||
}
|
||||
|
||||
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
|
||||
if load == nil {
|
||||
return &ConfigSnapshot{}
|
||||
}
|
||||
return &ConfigSnapshot{load: sync.OnceValues(load)}
|
||||
}
|
||||
|
||||
// MultiAppConfig returns the captured persistent config and load error.
|
||||
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
|
||||
if s == nil || s.load == nil {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return s.load()
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigSnapshotLoadsOnce(t *testing.T) {
|
||||
calls := 0
|
||||
want := &MultiAppConfig{}
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return want, nil
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config != want {
|
||||
t.Fatal("snapshot returned a different config instance")
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
|
||||
config, err := (&ConfigSnapshot{}).MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigSnapshotFromPreservesCapturedConfig(t *testing.T) {
|
||||
want := &MultiAppConfig{CurrentApp: "captured"}
|
||||
snapshot := NewConfigSnapshotFrom(want)
|
||||
|
||||
got, err := snapshot.MultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want || got.CurrentApp != "captured" {
|
||||
t.Fatalf("MultiAppConfig() = %#v, want captured pointer %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotCachesError(t *testing.T) {
|
||||
calls := 0
|
||||
want := errors.New("load failed")
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return nil, want
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, want) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,7 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
disabled := false
|
||||
config := &MultiAppConfig{
|
||||
RiskControl: &disabled,
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||
@@ -86,9 +84,6 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
if got.Apps[0].Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
if got.RiskControl == nil || *got.RiskControl {
|
||||
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user